mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
feat(plugins): accept inline SVG and component icons
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.
The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
const PLUGIN_ID = "plugin-icons-e2e";
|
||||
const SVG_PATH = "M4 12l5 5L20 6";
|
||||
|
||||
const pluginClientSource = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
|
||||
|
||||
function BadgeIcon({ size, color }) {
|
||||
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
function Panel() {
|
||||
return <View><Text>Plugin icon panel</Text></View>;
|
||||
}
|
||||
|
||||
const panels = [
|
||||
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
|
||||
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
|
||||
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
|
||||
];
|
||||
|
||||
export default function contribute(client) {
|
||||
for (const panel of panels) {
|
||||
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
|
||||
client.addCommandCenterItem({
|
||||
id: panel.id,
|
||||
title: "Open " + panel.title,
|
||||
icon: panel.icon,
|
||||
context: "workspace",
|
||||
onSelect({ openPanel }) { openPanel(panel.id); },
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
function pluginTab(page: Page, panelId: string): Locator {
|
||||
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
|
||||
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const screenshot = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
|
||||
}
|
||||
|
||||
test.describe("plugin icons", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
|
||||
await waitForWorkspaceInSidebar(page, target);
|
||||
await switchWorkspaceViaSidebar({ page, ...target });
|
||||
|
||||
await test.step("Command Center rows draw each icon form", async () => {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("icon panel");
|
||||
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
|
||||
await expect(
|
||||
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
panel
|
||||
.getByRole("button", { name: "Open SVG icon panel", exact: true })
|
||||
.locator(`path[d="${SVG_PATH}"]`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel
|
||||
.getByRole("button", { name: "Open Component icon panel", exact: true })
|
||||
.getByTestId("plugin-component-icon"),
|
||||
).toBeVisible();
|
||||
await capture(page, testInfo, "command-center-icons");
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await test.step("workspace panel tabs draw each icon form", async () => {
|
||||
await runCommand(page, "Open Lucide icon panel");
|
||||
await runCommand(page, "Open SVG icon panel");
|
||||
await runCommand(page, "Open Component icon panel");
|
||||
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
|
||||
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
|
||||
await expect(
|
||||
pluginTab(page, "component").getByTestId("plugin-component-icon"),
|
||||
).toBeVisible();
|
||||
await capture(page, testInfo, "workspace-tab-icons");
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await workspace.cleanup();
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@getpaseo/plugin";
|
||||
import {
|
||||
PluginAttachmentItemSchema,
|
||||
type PluginAttachmentItem,
|
||||
type PluginSerializableIcon,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
|
||||
export const PluginResourceComposerAttachmentSchema = z.object({
|
||||
@@ -8,7 +12,8 @@ export const PluginResourceComposerAttachmentSchema = z.object({
|
||||
pluginId: z.string().min(1),
|
||||
sourceId: z.string().min(1),
|
||||
sourceTitle: z.string().min(1),
|
||||
sourceIcon: z.string().min(1),
|
||||
// Validated when the source registered; a draft keeps the icon its source had then.
|
||||
sourceIcon: z.union([z.string().min(1), z.object({ svg: z.string().min(1) })]),
|
||||
item: PluginAttachmentItemSchema,
|
||||
});
|
||||
|
||||
@@ -20,7 +25,7 @@ export interface PluginResourceSourceIdentity {
|
||||
pluginId: string;
|
||||
sourceId: string;
|
||||
sourceTitle: string;
|
||||
sourceIcon: string;
|
||||
sourceIcon: PluginSerializableIcon;
|
||||
}
|
||||
|
||||
export function createPluginResourceAttachment(
|
||||
|
||||
@@ -2,15 +2,18 @@ import { useCallback, useMemo, useState, type ReactElement, type RefObject } fro
|
||||
import { View } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin";
|
||||
import type {
|
||||
PluginAttachmentItem,
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginSerializableIcon,
|
||||
} from "@getpaseo/plugin";
|
||||
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AttachmentMenuItem } from "@/composer/input/input";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { PluginIconView } from "../icons";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
|
||||
@@ -19,8 +22,8 @@ const SEARCH_STALE_TIME_MS = 30_000;
|
||||
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
|
||||
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
return <Icon size={ICON_SIZE.md} color={color} />;
|
||||
function SourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
|
||||
return <PluginIconView icon={icon} size={ICON_SIZE.md} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedSourceIcon = withUnistyles(SourceIcon);
|
||||
@@ -145,15 +148,12 @@ export function usePluginAttachmentPicker(
|
||||
);
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
sources.map(({ key, source }) => {
|
||||
const Icon = resolvePluginIcon(source.icon);
|
||||
return {
|
||||
id: `plugin:${key}`,
|
||||
label: `Attach ${source.title}`,
|
||||
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
|
||||
onSelect: () => setActiveKey(key),
|
||||
};
|
||||
}),
|
||||
sources.map(({ key, source }) => ({
|
||||
id: `plugin:${key}`,
|
||||
label: `Attach ${source.title}`,
|
||||
icon: <ThemedSourceIcon icon={source.icon} uniProps={iconColorMapping} />,
|
||||
onSelect: () => setActiveKey(key),
|
||||
})),
|
||||
[sources],
|
||||
);
|
||||
if (!active) return { menuItems, picker: null };
|
||||
|
||||
@@ -3,19 +3,14 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import type { PluginSerializableIcon } from "@getpaseo/plugin";
|
||||
import { PluginIconView } from "../icons";
|
||||
import type { PluginResourceComposerAttachment } from "./model";
|
||||
|
||||
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function ResourceIcon({
|
||||
Icon,
|
||||
color = "",
|
||||
}: {
|
||||
Icon: ReturnType<typeof resolvePluginIcon>;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={ICON_SIZE.sm} color={color} />;
|
||||
function ResourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
|
||||
return <PluginIconView icon={icon} size={ICON_SIZE.sm} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedResourceIcon = withUnistyles(ResourceIcon);
|
||||
@@ -39,12 +34,11 @@ export function PluginResourceAttachmentPill({
|
||||
openLabel,
|
||||
removeLabel,
|
||||
}: PluginResourceAttachmentPillProps) {
|
||||
const Icon = resolvePluginIcon(attachment.sourceIcon);
|
||||
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
|
||||
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
|
||||
const icon = useMemo(
|
||||
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
|
||||
[Icon],
|
||||
() => <ThemedResourceIcon icon={attachment.sourceIcon} uniProps={iconColorMapping} />,
|
||||
[attachment.sourceIcon],
|
||||
);
|
||||
return (
|
||||
<AttachmentPill
|
||||
|
||||
@@ -30,8 +30,9 @@ function installation(): InstalledPlugin {
|
||||
|
||||
function store() {
|
||||
return new PluginButtonStore({
|
||||
validateIconName(name) {
|
||||
if (name !== "Scan") throw new Error("Unknown icon");
|
||||
parseIcon(icon) {
|
||||
if (icon !== "Scan") throw new Error("Unknown icon");
|
||||
return icon;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { parsePluginIcon } from "../icons";
|
||||
import { PluginButtonStore } from "./model";
|
||||
|
||||
export const pluginButtonStore = new PluginButtonStore({ validateIconName: resolvePluginIcon });
|
||||
export const pluginButtonStore = new PluginButtonStore({
|
||||
parseIcon: (icon) => parsePluginIcon("Plugin button", icon),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
|
||||
|
||||
export interface ButtonValidation {
|
||||
validateIconName(name: string): void;
|
||||
/** Returns the normalized icon, or throws naming why the icon is unusable. */
|
||||
parseIcon(icon: PluginButtonIcon): PluginButtonIcon;
|
||||
}
|
||||
|
||||
export function requireButtonId(value: string): string {
|
||||
@@ -29,12 +30,6 @@ function isComponent(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): void {
|
||||
if (typeof icon === "string") validation.validateIconName(icon);
|
||||
else if (!isComponent(icon))
|
||||
throw new Error("Plugin button icon must be a Lucide name or component");
|
||||
}
|
||||
|
||||
function validateBehavior(
|
||||
behavior: PluginButtonBehavior,
|
||||
validation: ButtonValidation,
|
||||
@@ -57,9 +52,9 @@ function validateBehavior(
|
||||
ids.add(item.id);
|
||||
if (item.kind === "separator") return { ...item };
|
||||
if (item.kind !== "item") throw new Error("Invalid plugin button menu entry");
|
||||
if (item.icon !== undefined) validateIcon(item.icon, validation);
|
||||
return {
|
||||
...item,
|
||||
icon: item.icon === undefined ? undefined : validation.parseIcon(item.icon),
|
||||
title: requireText(item.title, "menu item title"),
|
||||
visible: optionalBoolean(item.visible, true),
|
||||
disabled: optionalBoolean(item.disabled, false),
|
||||
@@ -83,9 +78,9 @@ export function validateButton(
|
||||
button: PluginButton,
|
||||
validation: ButtonValidation,
|
||||
): ResolvedPluginButton {
|
||||
validateIcon(button.icon, validation);
|
||||
return {
|
||||
...button,
|
||||
icon: validation.parseIcon(button.icon),
|
||||
title: requireText(button.title, "title"),
|
||||
label: button.label === undefined ? undefined : requireText(button.label, "label"),
|
||||
visible: optionalBoolean(button.visible, true),
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { Icon } from "../icons";
|
||||
import { pluginIconComponent } from "../icons";
|
||||
import {
|
||||
PluginEnvironmentProvider,
|
||||
PluginPopoverContent,
|
||||
@@ -79,6 +79,7 @@ function ButtonEnvironment({ view, children }: { view: ButtonView; children: Rea
|
||||
function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }) {
|
||||
const color = view.props.theme.colors.foregroundMuted;
|
||||
const size = view.entry.placement === "composer" ? 14 : 16;
|
||||
const Icon = pluginIconComponent(icon);
|
||||
return (
|
||||
<View
|
||||
style={styles.icon}
|
||||
@@ -87,24 +88,11 @@ function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
{typeof icon === "string" ? (
|
||||
<Icon name={icon} size={size} color={color} />
|
||||
) : (
|
||||
renderCustomIcon(icon, view, size, color)
|
||||
)}
|
||||
<Icon {...view.props} size={size} color={color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCustomIcon(
|
||||
CustomIcon: Exclude<PluginButtonIcon, string>,
|
||||
view: ButtonView,
|
||||
size: number,
|
||||
color: string,
|
||||
) {
|
||||
return <CustomIcon {...view.props} size={size} color={color} />;
|
||||
}
|
||||
|
||||
function pressButton(view: ButtonView, path: readonly string[]) {
|
||||
void pluginButtonStore.run(view.entry.key, path).catch((error: unknown) => {
|
||||
view.toast.error(error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
|
||||
import type { CommandCenterContribution } from "@/command-center/contributions";
|
||||
import { getCommandCenterIcon } from "@/command-center/icon";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { pluginIconComponent } from "../icons";
|
||||
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginCapabilities, type PluginNavigation } from "../actions";
|
||||
@@ -83,7 +83,7 @@ export function buildPluginCommandCenterContributions(
|
||||
kind: "action",
|
||||
title: item.title,
|
||||
sectionTitle: plugin.id,
|
||||
icon: getCommandCenterIcon(resolvePluginIcon(item.icon)),
|
||||
icon: getCommandCenterIcon(pluginIconComponent(item.icon)),
|
||||
},
|
||||
run,
|
||||
});
|
||||
|
||||
@@ -381,6 +381,72 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]);
|
||||
});
|
||||
|
||||
it("registers inline SVG and component icons", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function ReviewPanel() { return null; }
|
||||
function BadgeIcon() { return null; }
|
||||
plugin.addWorkspacePanel({
|
||||
id: "review",
|
||||
title: "Review",
|
||||
icon: { svg: ${JSON.stringify(` ${svg}\n`)} },
|
||||
context: "workspace",
|
||||
Component: ReviewPanel,
|
||||
});
|
||||
plugin.addCommandCenterItem({
|
||||
id: "open-review",
|
||||
title: "Open review",
|
||||
icon: BadgeIcon,
|
||||
context: "global",
|
||||
onSelect() {},
|
||||
});
|
||||
plugin.addAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Issues",
|
||||
icon: { svg: ${JSON.stringify(svg)} },
|
||||
pickerTitle: "Attach issue",
|
||||
searchPlaceholder: "Search",
|
||||
search: { name: "issues.search", input: {}, output: {} },
|
||||
});
|
||||
`),
|
||||
);
|
||||
|
||||
expect(plugin.workspacePanels[0]?.icon).toEqual({ svg });
|
||||
expect(plugin.commandCenterItems[0]?.icon).toHaveProperty("name", "BadgeIcon");
|
||||
expect(plugin.attachmentSources[0]?.icon).toEqual({ svg });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"workspace panel",
|
||||
`plugin.addWorkspacePanel({ id: "review", title: "Review", icon: ICON, context: "workspace", Component() { return null; } })`,
|
||||
"Workspace panel review has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
[
|
||||
"Command Center item",
|
||||
`plugin.addCommandCenterItem({ id: "review", title: "Review", icon: ICON, context: "global", onSelect() {} })`,
|
||||
"Command Center item review has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
[
|
||||
"settings screen",
|
||||
`plugin.addSettingsScreen({ id: "review", title: "Review", icon: ICON, Component() { return null; } })`,
|
||||
"Settings screen review has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
[
|
||||
"attachment source",
|
||||
`plugin.addAttachmentSource({ id: "review", title: "Review", icon: ICON, pickerTitle: "Pick", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } })`,
|
||||
"Attachment source review has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
])("fails the plugin when a %s registers an unsafe SVG", (_kind, registration, message) => {
|
||||
const icon = JSON.stringify({ svg: "<svg><script>run()</script></svg>" });
|
||||
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle("review", bundle(registration.replace("ICON", icon))),
|
||||
).toThrow(message);
|
||||
});
|
||||
|
||||
it("normalizes and validates workspace panel locations", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
|
||||
import { pluginReactNativeRuntime } from "./react-native/runtime";
|
||||
import { parsePluginThemeContribution } from "./themes";
|
||||
|
||||
@@ -199,9 +199,9 @@ export function runPluginClientBundle(
|
||||
if (settingsScreenIds.has(screenId))
|
||||
throw new Error(`Duplicate settings screen: ${screenId}`);
|
||||
if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`);
|
||||
resolvePluginIcon(contribution.icon);
|
||||
const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon);
|
||||
settingsScreenIds.add(screenId);
|
||||
return register(collector.settingsScreens, { ...contribution, id: screenId }, () =>
|
||||
return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () =>
|
||||
settingsScreenIds.delete(screenId),
|
||||
);
|
||||
},
|
||||
@@ -241,12 +241,10 @@ export function runPluginClientBundle(
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
addSidebarItem(contribution: PluginSidebarContribution) {
|
||||
const normalizedId = requireId(contribution.id, "sidebar item id");
|
||||
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
|
||||
resolvePluginIcon(contribution.icon.trim());
|
||||
const legacy = {
|
||||
id: normalizedId,
|
||||
title: contribution.title.trim(),
|
||||
icon: contribution.icon.trim(),
|
||||
icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon),
|
||||
surface: requireId(contribution.surface, "sidebar surface id"),
|
||||
};
|
||||
// Shares the header's ids: the sidebar orders both kinds of item under one key.
|
||||
@@ -262,16 +260,14 @@ export function runPluginClientBundle(
|
||||
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const icon = contribution.icon.trim();
|
||||
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
|
||||
if (!icon) throw new Error(`Workspace panel ${normalizedId} has no icon`);
|
||||
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
|
||||
if (contribution.context !== "workspace" && contribution.context !== "agent") {
|
||||
throw new Error(`Workspace panel ${normalizedId} has invalid context`);
|
||||
}
|
||||
if (typeof contribution.Component !== "function") {
|
||||
throw new Error(`Workspace panel ${normalizedId} is not a component`);
|
||||
}
|
||||
resolvePluginIcon(icon);
|
||||
const locations = normalizePanelLocations(normalizedId, contribution.locations);
|
||||
workspacePanelIds.add(normalizedId);
|
||||
return register(
|
||||
@@ -292,9 +288,8 @@ export function runPluginClientBundle(
|
||||
throw new Error(`Duplicate Command Center item: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const icon = contribution.icon.trim();
|
||||
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
|
||||
if (!icon) throw new Error(`Command Center item ${normalizedId} has no icon`);
|
||||
const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon);
|
||||
if (
|
||||
contribution.context !== "global" &&
|
||||
contribution.context !== "workspace" &&
|
||||
@@ -305,7 +300,6 @@ export function runPluginClientBundle(
|
||||
if (typeof contribution.onSelect !== "function") {
|
||||
throw new Error(`Command Center item ${normalizedId} has no callback`);
|
||||
}
|
||||
resolvePluginIcon(icon);
|
||||
commandCenterItemIds.add(normalizedId);
|
||||
return register(
|
||||
collector.commandCenterItems,
|
||||
@@ -350,18 +344,19 @@ export function runPluginClientBundle(
|
||||
throw new Error(`Duplicate attachment source: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const icon = contribution.icon.trim();
|
||||
const pickerTitle = contribution.pickerTitle.trim();
|
||||
const searchPlaceholder = contribution.searchPlaceholder.trim();
|
||||
const method = contribution.search.name.trim();
|
||||
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
|
||||
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
|
||||
const icon = parseSerializablePluginIcon(
|
||||
`Attachment source ${normalizedId}`,
|
||||
contribution.icon,
|
||||
);
|
||||
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
|
||||
if (!searchPlaceholder) {
|
||||
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
|
||||
}
|
||||
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
|
||||
resolvePluginIcon(icon);
|
||||
attachmentSourceIds.add(normalizedId);
|
||||
return register(
|
||||
collector.attachmentSources,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Settings } from "lucide-react-native";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Icon } from "./icons";
|
||||
|
||||
describe("Icon", () => {
|
||||
it("renders a host Lucide icon with the requested presentation", () => {
|
||||
expect(Icon({ name: "Settings", size: 18, color: "#123456" })).toMatchObject({
|
||||
type: Settings,
|
||||
props: { size: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing for an unknown icon name", () => {
|
||||
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
|
||||
expect(Icon({ name: "Icon" })).toBeNull();
|
||||
expect(Icon({ name: "createLucideIcon" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Settings } from "lucide-react-native";
|
||||
import React, { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginIconProps } from "@getpaseo/plugin";
|
||||
import { Icon, parsePluginIcon, parseSerializablePluginIcon, PluginIconView } from "./icons";
|
||||
|
||||
const SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
|
||||
const SVG_ICON = { svg: SVG };
|
||||
|
||||
const GLYPHS = new Set<unknown>([Settings, SvgXml]);
|
||||
|
||||
/** Expands function components down to what reaches the screen: a Lucide glyph, SvgXml, or DOM. */
|
||||
function draw(node: ReactNode): ReactElement | null {
|
||||
if (!isValidElement(node)) return null;
|
||||
const { type } = node;
|
||||
if (typeof type !== "function" || GLYPHS.has(type)) return node;
|
||||
return draw((type as (props: unknown) => ReactNode)(node.props));
|
||||
}
|
||||
|
||||
function BadgeIcon({ size, color }: PluginIconProps) {
|
||||
return <span data-size={size} data-color={color} />;
|
||||
}
|
||||
|
||||
describe("PluginIconView", () => {
|
||||
it("draws a Lucide name as that Lucide icon", () => {
|
||||
expect(draw(<PluginIconView icon="Settings" size={18} color="#123456" />)).toMatchObject({
|
||||
type: Settings,
|
||||
props: { size: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("draws inline SVG sized to the surface, with color reaching currentColor", () => {
|
||||
expect(draw(<PluginIconView icon={SVG_ICON} size={18} color="#123456" />)).toMatchObject({
|
||||
type: SvgXml,
|
||||
props: { xml: SVG, width: 18, height: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("draws a component with the surface's size and color", () => {
|
||||
expect(draw(<PluginIconView icon={BadgeIcon} size={18} color="#123456" />)).toMatchObject({
|
||||
type: "span",
|
||||
props: { "data-size": 18, "data-color": "#123456" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginIcon", () => {
|
||||
it("normalizes each icon form", () => {
|
||||
expect(parsePluginIcon("Workspace panel files", " Settings ")).toBe("Settings");
|
||||
expect(parsePluginIcon("Workspace panel files", { svg: `\n${SVG}\n` })).toEqual({ svg: SVG });
|
||||
expect(parsePluginIcon("Workspace panel files", BadgeIcon)).toBe(BadgeIcon);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "Workspace panel files has no icon"],
|
||||
[
|
||||
"NotALucideIcon",
|
||||
'Workspace panel files has an invalid icon: unknown Lucide icon "NotALucideIcon"',
|
||||
],
|
||||
[
|
||||
{ svg: "<svg><script>run()</script></svg>" },
|
||||
"Workspace panel files has an invalid icon: script elements are not allowed",
|
||||
],
|
||||
[{ svg: "hello" }, "Workspace panel files has an invalid icon: not an SVG document"],
|
||||
[
|
||||
{ path: "icon.svg" },
|
||||
"Workspace panel files has an invalid icon: expected a Lucide icon name, { svg }, or a component",
|
||||
],
|
||||
])("rejects %j", (icon, message) => {
|
||||
expect(() => parsePluginIcon("Workspace panel files", icon as never)).toThrow(message);
|
||||
});
|
||||
|
||||
it("refuses a component where the icon is stored as data", () => {
|
||||
expect(() =>
|
||||
parseSerializablePluginIcon("Attachment source issues", BadgeIcon as never),
|
||||
).toThrow(
|
||||
"Attachment source issues has an invalid icon: expected a Lucide icon name or { svg }",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Icon", () => {
|
||||
it("renders a host Lucide icon by name with the requested presentation", () => {
|
||||
expect(draw(Icon({ name: "Settings", size: 18, color: "#123456" }))).toMatchObject({
|
||||
type: Settings,
|
||||
props: { size: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a plugin's own SVG through the icon prop", () => {
|
||||
expect(draw(Icon({ icon: { svg: SVG }, size: 18, color: "#123456" }))).toMatchObject({
|
||||
type: SvgXml,
|
||||
props: { xml: SVG, width: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing for an unusable icon", () => {
|
||||
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
|
||||
expect(Icon({ name: "Icon" })).toBeNull();
|
||||
expect(Icon({ name: "createLucideIcon" })).toBeNull();
|
||||
expect(Icon({ icon: { svg: '<svg onload="run()"></svg>' } })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,150 @@
|
||||
import { createElement, type ReactElement } from "react";
|
||||
import { createElement, type ComponentType, type ReactElement } from "react";
|
||||
import * as LucideIcons from "lucide-react-native";
|
||||
import type { PluginIconProps } from "@getpaseo/plugin/client";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import {
|
||||
findSvgIconProblem,
|
||||
type PluginIconProps,
|
||||
type PluginSerializableIcon,
|
||||
type PluginSvgIcon,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { PluginIcon } from "@getpaseo/plugin/client";
|
||||
import type { IconProps } from "@getpaseo/plugin/client/react-native";
|
||||
import { SvgIcon } from "@/components/provider-icons";
|
||||
|
||||
function findPluginIcon(name: string): LucideIcon | null {
|
||||
/** Host surfaces may thin a Lucide stroke; SVG and plugin components ignore it. */
|
||||
type HostIconProps = PluginIconProps & { strokeWidth?: number };
|
||||
/** A host-drawn icon. A plain function, so it stands in for any plugin icon component type. */
|
||||
type HostIcon = (props: HostIconProps) => ReactElement | null;
|
||||
|
||||
const lucideIconComponents = new Map<string, HostIcon>();
|
||||
const svgIconComponents = new Map<string, HostIcon>();
|
||||
|
||||
function findLucideIcon(name: string): LucideIcon | null {
|
||||
const candidate = Reflect.get(LucideIcons, name);
|
||||
if (candidate === LucideIcons.Icon || candidate === LucideIcons.createLucideIcon) return null;
|
||||
const isComponent =
|
||||
typeof candidate === "function" ||
|
||||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
|
||||
return isComponent ? (candidate as LucideIcon) : null;
|
||||
return isComponent(candidate) ? (candidate as LucideIcon) : null;
|
||||
}
|
||||
|
||||
export function resolvePluginIcon(name: string): LucideIcon {
|
||||
const icon = findPluginIcon(name);
|
||||
if (!icon) throw new Error(`Unknown Lucide icon: ${name}`);
|
||||
function isComponent(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "function" ||
|
||||
(typeof value === "object" && value !== null && "$$typeof" in value)
|
||||
);
|
||||
}
|
||||
|
||||
function isSvgIcon(icon: unknown): icon is PluginSvgIcon {
|
||||
return (
|
||||
typeof icon === "object" &&
|
||||
icon !== null &&
|
||||
!("$$typeof" in icon) &&
|
||||
typeof Reflect.get(icon, "svg") === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isComponentIcon<Props extends PluginIconProps>(
|
||||
icon: PluginIcon<Props>,
|
||||
): icon is ComponentType<Props> {
|
||||
return isComponent(icon);
|
||||
}
|
||||
|
||||
function invalidIcon(owner: string, reason: string): Error {
|
||||
return new Error(`${owner} has an invalid icon: ${reason}`);
|
||||
}
|
||||
|
||||
/** Validates an icon that is stored with the data it labels. Returns the normalized icon. */
|
||||
export function parseSerializablePluginIcon(
|
||||
owner: string,
|
||||
icon: PluginSerializableIcon,
|
||||
): PluginSerializableIcon {
|
||||
if (typeof icon === "string") {
|
||||
const name = icon.trim();
|
||||
if (!name) throw new Error(`${owner} has no icon`);
|
||||
if (!findLucideIcon(name)) throw invalidIcon(owner, `unknown Lucide icon "${name}"`);
|
||||
return name;
|
||||
}
|
||||
if (!isSvgIcon(icon)) throw invalidIcon(owner, "expected a Lucide icon name or { svg }");
|
||||
const svg = icon.svg.trim();
|
||||
const problem = findSvgIconProblem(svg);
|
||||
if (problem) throw invalidIcon(owner, problem);
|
||||
return { svg };
|
||||
}
|
||||
|
||||
/** Validates a contribution's icon once, at registration. Rendering trusts the result. */
|
||||
export function parsePluginIcon<Props extends PluginIconProps>(
|
||||
owner: string,
|
||||
icon: PluginIcon<Props>,
|
||||
): PluginIcon<Props> {
|
||||
if (isComponentIcon(icon)) return icon;
|
||||
if (typeof icon !== "string" && !isSvgIcon(icon)) {
|
||||
throw invalidIcon(owner, "expected a Lucide icon name, { svg }, or a component");
|
||||
}
|
||||
return parseSerializablePluginIcon(owner, icon);
|
||||
}
|
||||
|
||||
function MissingIcon(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function createLucideIcon(name: string, Lucide: LucideIcon): HostIcon {
|
||||
function PluginLucideIcon({ size, color, strokeWidth }: HostIconProps) {
|
||||
return createElement(Lucide, { size, color, strokeWidth });
|
||||
}
|
||||
PluginLucideIcon.displayName = `PluginLucideIcon(${name})`;
|
||||
return PluginLucideIcon;
|
||||
}
|
||||
|
||||
function lucideIconComponent(name: string): HostIcon {
|
||||
const cached = lucideIconComponents.get(name);
|
||||
if (cached) return cached;
|
||||
const Lucide = findLucideIcon(name);
|
||||
if (!Lucide) return MissingIcon;
|
||||
const icon = createLucideIcon(name, Lucide);
|
||||
lucideIconComponents.set(name, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function Icon({ name, size, color }: PluginIconProps): ReactElement | null {
|
||||
const icon = findPluginIcon(name);
|
||||
return icon ? createElement(icon, { size, color }) : null;
|
||||
function svgIconComponent(svg: string): HostIcon {
|
||||
const cached = svgIconComponents.get(svg);
|
||||
if (cached) return cached;
|
||||
function PluginSvgIcon({ size, color }: PluginIconProps) {
|
||||
return createElement(SvgIcon, { svg, size, color });
|
||||
}
|
||||
svgIconComponents.set(svg, PluginSvgIcon);
|
||||
return PluginSvgIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable component for a registered icon, for surfaces that take an icon component. Lucide and
|
||||
* SVG icons draw from `size` and `color` only; a plugin component receives every prop.
|
||||
*/
|
||||
export function pluginIconComponent<Props extends PluginIconProps>(
|
||||
icon: PluginIcon<Props>,
|
||||
): ComponentType<Props> {
|
||||
if (typeof icon === "string") return lucideIconComponent(icon);
|
||||
if (isSvgIcon(icon)) return svgIconComponent(icon.svg);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export interface PluginIconViewProps extends PluginIconProps {
|
||||
icon: PluginIcon;
|
||||
}
|
||||
|
||||
/** Draws a registered plugin icon. `color` tints Lucide icons and `currentColor` in SVG. */
|
||||
export function PluginIconView({ icon, size, color }: PluginIconViewProps): ReactElement {
|
||||
return createElement(pluginIconComponent(icon), { size, color });
|
||||
}
|
||||
|
||||
function isRenderable(icon: PluginIcon): boolean {
|
||||
if (typeof icon === "string") return findLucideIcon(icon) !== null;
|
||||
if (isSvgIcon(icon)) return findSvgIconProblem(icon.svg) === null;
|
||||
return isComponent(icon);
|
||||
}
|
||||
|
||||
/** The kit `Icon`. Plugin code passes it anything, so an unusable icon renders nothing. */
|
||||
export function Icon(props: IconProps): ReactElement | null {
|
||||
const icon = props.icon === undefined ? props.name : props.icon;
|
||||
if (!isRenderable(icon)) return null;
|
||||
const size = props.size ?? 24;
|
||||
const color = props.color ?? "currentColor";
|
||||
return createElement(PluginIconView, { icon, size, color });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PluginSidebarItemContribution,
|
||||
PluginSidebarSection,
|
||||
} from "./types";
|
||||
import type { PluginIcon } from "@getpaseo/plugin/client";
|
||||
|
||||
export interface PluginSidebarTarget {
|
||||
plugin: InstalledPlugin;
|
||||
@@ -36,7 +37,7 @@ export type PluginSidebarGroup =
|
||||
| (PluginSidebarGroupBase & { kind: "item"; targets: PluginSidebarTarget[] })
|
||||
| (PluginSidebarGroupBase & {
|
||||
kind: "legacy";
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
targets: PluginLegacySidebarTarget[];
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { SidebarIcon, SidebarRowProps } from "@getpaseo/plugin/client/ui";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { SidebarRowProps } from "@getpaseo/plugin/client/ui";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarSeparator as AppSidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { parsePluginIcon, pluginIconComponent } from "../icons";
|
||||
import { useSidebarItemFrame } from "./frame";
|
||||
|
||||
function resolveIcon(icon: SidebarIcon) {
|
||||
return typeof icon === "string" ? resolvePluginIcon(icon) : icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* An item may render several rows. Any press inside a row, on the row or on its trailing content,
|
||||
* makes that row the popover anchor: the capture-phase responder check runs before the pressed
|
||||
@@ -29,6 +25,11 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
|
||||
anchorTo(rowRef.current);
|
||||
return false;
|
||||
}, [anchorTo]);
|
||||
// Rows get their icon at render, not registration, so an invalid icon throws to the item's boundary.
|
||||
const iconComponent = useMemo(
|
||||
() => (icon ? pluginIconComponent(parsePluginIcon("Sidebar row", icon)) : null),
|
||||
[icon],
|
||||
);
|
||||
// Keyboard activation skips the responder system, so the row's own press anchors as well.
|
||||
const handlePress = useCallback(() => {
|
||||
anchorTo(rowRef.current);
|
||||
@@ -37,7 +38,7 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
|
||||
return (
|
||||
<View ref={rowRef} collapsable={false} onStartShouldSetResponderCapture={anchorHere}>
|
||||
<SidebarHeaderRow
|
||||
icon={icon ? resolveIcon(icon) : null}
|
||||
icon={iconComponent}
|
||||
label={label ?? frame.title}
|
||||
onPress={handlePress}
|
||||
isActive={active}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { router, usePathname } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { rememberPluginContributionHost } from "../contribution-host";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { pluginIconComponent } from "../icons";
|
||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
|
||||
import { selectPluginSidebarTarget, type PluginSidebarGroup } from "../sidebar-groups";
|
||||
|
||||
@@ -33,7 +33,7 @@ export function LegacyPluginSidebarRow({
|
||||
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={resolvePluginIcon(group.icon)}
|
||||
icon={pluginIconComponent(group.icon)}
|
||||
label={group.title}
|
||||
onPress={navigate}
|
||||
isActive={isActive}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import type { PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginIcon, PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useCallback, useMemo, type ComponentType } from "react";
|
||||
@@ -17,7 +17,7 @@ import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { usePluginHostNavigation } from "./host-navigation";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { PluginIconView } from "./icons";
|
||||
import { toPluginTheme } from "./theme";
|
||||
import { useInstalledPlugin, usePluginInstallations } from "./registry";
|
||||
import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes";
|
||||
@@ -47,14 +47,8 @@ function routeParam(value: string | string[] | undefined): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function PluginHeaderIcon({
|
||||
Icon,
|
||||
color = "",
|
||||
}: {
|
||||
Icon: ReturnType<typeof resolvePluginIcon>;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={16} color={color} />;
|
||||
function PluginHeaderIcon({ icon, color = "" }: { icon: PluginIcon; color?: string }) {
|
||||
return <PluginIconView icon={icon} size={16} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
|
||||
@@ -162,7 +156,7 @@ export function PluginSurfaceScreen() {
|
||||
: (sidebarItem?.title ?? (pluginId || "Plugin")),
|
||||
[params, pluginId, sidebarItem, surface],
|
||||
);
|
||||
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
|
||||
const icon = sidebarItem?.icon ?? null;
|
||||
const close = useCallback(() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
else router.replace(`/h/${encodeURIComponent(serverId)}`);
|
||||
@@ -172,15 +166,15 @@ export function PluginSurfaceScreen() {
|
||||
const headerLeft = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{Icon ? (
|
||||
{icon ? (
|
||||
<HeaderIconBadge>
|
||||
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
|
||||
<ThemedPluginHeaderIcon icon={icon} uniProps={mutedColorMapping} />
|
||||
</HeaderIconBadge>
|
||||
) : null}
|
||||
<ScreenTitle testID="plugin-surface-title">{title}</ScreenTitle>
|
||||
</>
|
||||
),
|
||||
[Icon, title],
|
||||
[icon, title],
|
||||
);
|
||||
const hostFilter = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock("../client-runtime", () => ({
|
||||
}));
|
||||
vi.mock("../icons", () => ({
|
||||
Icon: () => null,
|
||||
resolvePluginIcon: () => () => null,
|
||||
pluginIconComponent: () => () => null,
|
||||
}));
|
||||
|
||||
import { pluginRegistry } from "../registry";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { usePluginHostNavigation } from "../host-navigation";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { pluginIconComponent } from "../icons";
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import { PluginInstallationProvider } from "../installation-provider";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
@@ -144,7 +144,7 @@ function usePluginPanelDescriptor(
|
||||
subtitle: target.pluginId,
|
||||
tooltip: panel.title,
|
||||
titleState: "ready",
|
||||
icon: resolvePluginIcon(panel.icon),
|
||||
icon: pluginIconComponent(panel.icon),
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useCallback, useMemo, type ComponentType, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type SidebarNavItem,
|
||||
type SidebarSection,
|
||||
} from "@/sidebar-nav/model";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { pluginIconComponent } from "@/plugins/icons";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -50,15 +50,17 @@ const BUILTIN_ICONS: Record<BuiltinSidebarItemId, LucideIcon> = {
|
||||
/** Plugin items register no icon, so they share this one; a legacy `addSidebarItem` keeps its own. */
|
||||
const PLUGIN_ICON = Blocks;
|
||||
|
||||
function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
type NavIconComponent = ComponentType<{ size: number; color: string }>;
|
||||
|
||||
function NavIcon({ Icon, color = "" }: { Icon: NavIconComponent; color?: string }) {
|
||||
return <Icon size={ICON_SIZE.md} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedNavIcon = withUnistyles(NavIcon);
|
||||
|
||||
function navItemIcon(item: SidebarNavItem): LucideIcon {
|
||||
function navItemIcon(item: SidebarNavItem): NavIconComponent {
|
||||
if (item.kind === "builtin") return BUILTIN_ICONS[item.id];
|
||||
return item.group.kind === "legacy" ? resolvePluginIcon(item.group.icon) : PLUGIN_ICON;
|
||||
return item.group.kind === "legacy" ? pluginIconComponent(item.group.icon) : PLUGIN_ICON;
|
||||
}
|
||||
|
||||
function navItemLabel(t: TFunction, item: SidebarNavItem): string {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Globe, SquarePen, SquareTerminal } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { pluginIconComponent } from "@/plugins/icons";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { pluginPanelSupportsLocation } from "@/plugins/workspace-panels/locations";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
@@ -212,7 +212,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
pluginItems.push({
|
||||
id: `plugin:${plugin.id}:${panel.id}`,
|
||||
label: panel.title,
|
||||
Icon: resolvePluginIcon(panel.icon),
|
||||
Icon: pluginIconComponent(panel.icon),
|
||||
disabled: false,
|
||||
panelKind: "plugin",
|
||||
launch: launchSelection(selection),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginHostProps } from "./contracts.js";
|
||||
import type { PluginIconProps } from "../icons.js";
|
||||
import type { PluginHostProps, PluginIcon } from "./contracts.js";
|
||||
|
||||
export type PluginButtonContext =
|
||||
| { context: "workspace"; workspaceId: string }
|
||||
| { context: "agent"; workspaceId: string; agentId: string };
|
||||
|
||||
export type PluginButtonIconProps = PluginHostProps &
|
||||
PluginButtonContext & { size: number; color: string };
|
||||
export type PluginButtonIconProps = PluginHostProps & PluginButtonContext & PluginIconProps;
|
||||
|
||||
export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void };
|
||||
|
||||
export type PluginButtonIcon = string | ComponentType<PluginButtonIconProps>;
|
||||
export type PluginButtonIcon = PluginIcon<PluginButtonIconProps>;
|
||||
|
||||
export type PluginButtonBehavior =
|
||||
| { kind: "action"; onPress(): void | Promise<void> }
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PluginTimelineTransformResult,
|
||||
PluginCleanup,
|
||||
} from "../contracts.js";
|
||||
import type { PluginIconProps, PluginSerializableIcon } from "../icons.js";
|
||||
|
||||
export interface PluginHostProps {
|
||||
theme: PluginTheme;
|
||||
@@ -87,11 +88,13 @@ export interface PluginSidebarItemContribution {
|
||||
Component: ComponentType<PluginSidebarItemProps>;
|
||||
}
|
||||
|
||||
export interface PluginIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
/**
|
||||
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
|
||||
* and color; buttons widen the props with their host and target context.
|
||||
*/
|
||||
export type PluginIcon<Props extends PluginIconProps = PluginIconProps> =
|
||||
| PluginSerializableIcon
|
||||
| ComponentType<Props>;
|
||||
|
||||
export type PluginPanelLocation = "workspace" | "explorer";
|
||||
|
||||
@@ -102,7 +105,7 @@ export interface PluginOpenPanelOptions {
|
||||
interface PluginWorkspacePanelBase {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
locations?: readonly PluginPanelLocation[];
|
||||
}
|
||||
|
||||
@@ -168,7 +171,7 @@ export type PluginWorkspacePanelContribution =
|
||||
export interface PluginSettingsScreenContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
Component: ComponentType<PluginSurfaceProps>;
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ export interface PluginSurfaceContribution {
|
||||
export interface PluginSidebarContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
@@ -259,7 +262,7 @@ export interface PluginAgentCommandContext extends PluginCommandCapabilities {
|
||||
interface PluginCommandCenterItemBase {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginIcon;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export type {
|
||||
PluginScreenLocation,
|
||||
PluginSidebarItemProps,
|
||||
PluginSidebarItemContribution,
|
||||
PluginIconProps,
|
||||
PluginIcon,
|
||||
PluginPanelLocation,
|
||||
PluginOpenPanelOptions,
|
||||
PluginWorkspacePanelProps,
|
||||
@@ -45,6 +45,7 @@ export type {
|
||||
PluginComposerPillContribution,
|
||||
PluginHeaderButtonContribution,
|
||||
} from "./buttons.js";
|
||||
export type { PluginIconProps, PluginSerializableIcon, PluginSvgIcon } from "../icons.js";
|
||||
export { usePaseo } from "./paseo-context.js";
|
||||
export { useAgent, useWorkspace } from "./client-state.js";
|
||||
export { useRpc } from "./rpc-context.js";
|
||||
|
||||
@@ -17,7 +17,8 @@ import type {
|
||||
TextInput as NativeTextInput,
|
||||
TextInputProps,
|
||||
} from "react-native";
|
||||
import type { PluginIconProps } from "./contracts.js";
|
||||
import type { PluginIconProps } from "../icons.js";
|
||||
import type { PluginIcon } from "./contracts.js";
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
@@ -53,12 +54,16 @@ export interface ToastApi {
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export declare const Icon: ComponentType<PluginIconProps>;
|
||||
/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */
|
||||
export type IconProps = Partial<PluginIconProps> &
|
||||
({ name: string; icon?: never } | { icon: PluginIcon; name?: never });
|
||||
|
||||
export declare const Icon: ComponentType<IconProps>;
|
||||
export declare const Modal: ModalComponent;
|
||||
export declare function useToast(): ToastApi;
|
||||
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
|
||||
|
||||
export type { PluginIconProps } from "./contracts.js";
|
||||
export type { PluginIconProps } from "../icons.js";
|
||||
|
||||
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
|
||||
export declare const ScrollView: ForwardRefExoticComponent<
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentType, ReactNode, Ref } from "react";
|
||||
import type { PluginIcon } from "./contracts.js";
|
||||
|
||||
export interface SettingsSectionProps {
|
||||
title: string;
|
||||
@@ -64,14 +65,14 @@ export interface ExternalLinkProps {
|
||||
}
|
||||
export declare const ExternalLink: ComponentType<ExternalLinkProps>;
|
||||
|
||||
export type SidebarIcon = string | ComponentType<{ size: number; color: string }>;
|
||||
export type SidebarIcon = PluginIcon;
|
||||
export interface SidebarRowProps {
|
||||
/**
|
||||
* Tells rows of one item apart when the item renders several: "bot-2". Unique within the item.
|
||||
* Omit it when the item renders one row.
|
||||
*/
|
||||
id?: string;
|
||||
/** A Lucide icon name or a component. */
|
||||
/** A Lucide icon name, inline SVG, or a component. */
|
||||
icon?: SidebarIcon;
|
||||
/** Defaults to the item's registered title. */
|
||||
label?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginSerializableIcon } from "./icons.js";
|
||||
import type { PluginRpcContract } from "./rpc.js";
|
||||
|
||||
export interface PluginTheme {
|
||||
@@ -73,7 +74,7 @@ export interface PluginThemeContribution {
|
||||
export interface PluginAttachmentSourceContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: PluginSerializableIcon;
|
||||
pickerTitle: string;
|
||||
searchPlaceholder: string;
|
||||
search: PluginRpcContract;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findSvgIconProblem } from "./icons.js";
|
||||
|
||||
describe("findSvgIconProblem", () => {
|
||||
it.each([
|
||||
["a plain SVG", '<svg viewBox="0 0 24 24"><path d="M0 0h24v24z" /></svg>'],
|
||||
["leading whitespace", '\n <svg viewBox="0 0 24 24"></svg>'],
|
||||
[
|
||||
"a fragment reference",
|
||||
'<svg><defs><path id="mark" d="M0 0h1v1z" /></defs><use href="#mark" /></svg>',
|
||||
],
|
||||
["currentColor fills", '<svg fill="currentColor"><circle r="4" /></svg>'],
|
||||
])("accepts %s", (_name, svg) => {
|
||||
expect(findSvgIconProblem(svg)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-SVG document", "hello", "not an SVG document"],
|
||||
["an <svgx> element", "<svgx></svgx>", "not an SVG document"],
|
||||
["a script", "<svg><script /></svg>", "script elements are not allowed"],
|
||||
["a foreign object", "<svg><foreignObject /></svg>", "foreignObject elements are not allowed"],
|
||||
["a style", "<svg><style /></svg>", "style elements are not allowed"],
|
||||
[
|
||||
"an event handler",
|
||||
'<svg><path onclick="run()" /></svg>',
|
||||
"event-handler attributes are not allowed",
|
||||
],
|
||||
[
|
||||
"a javascript URL",
|
||||
'<svg><a href="javascript:run()" /></svg>',
|
||||
"javascript URLs are not allowed",
|
||||
],
|
||||
[
|
||||
"an external href",
|
||||
'<svg><use href="https://example.com/icon.svg" /></svg>',
|
||||
"external href references are not allowed",
|
||||
],
|
||||
[
|
||||
"an external xlink:href",
|
||||
"<svg><image xlink:href=icon.png /></svg>",
|
||||
"external href references are not allowed",
|
||||
],
|
||||
["markup over 64 KiB", `<svg>${" ".repeat(64 * 1024)}</svg>`, "exceeds 64 KiB"],
|
||||
])("rejects %s", (_name, svg, reason) => {
|
||||
expect(findSvgIconProblem(svg)).toBe(reason);
|
||||
});
|
||||
|
||||
it("measures the size in UTF-8 bytes", () => {
|
||||
const multibyte = "é".repeat(32 * 1024);
|
||||
|
||||
expect(findSvgIconProblem(`<svg>${multibyte}</svg>`)).toBe("exceeds 64 KiB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/** What a component icon receives from the surface that draws it. */
|
||||
export interface PluginIconProps {
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Inline SVG markup. Paseo validates it when the contribution registers. */
|
||||
export interface PluginSvgIcon {
|
||||
svg: string;
|
||||
}
|
||||
|
||||
/** An icon that can be stored or sent: a Lucide icon name, or inline SVG. */
|
||||
export type PluginSerializableIcon = string | PluginSvgIcon;
|
||||
|
||||
export const PLUGIN_SVG_ICON_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const EXTERNAL_HREF = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
|
||||
|
||||
/**
|
||||
* Why Paseo refuses to render this SVG, or null when it is a self-contained document.
|
||||
* The daemon applies it to provider and usage-source icon files; the app to inline plugin icons.
|
||||
*/
|
||||
export function findSvgIconProblem(svg: string): string | null {
|
||||
if (new TextEncoder().encode(svg).byteLength > PLUGIN_SVG_ICON_MAX_BYTES) {
|
||||
return "exceeds 64 KiB";
|
||||
}
|
||||
if (!/^\s*<svg(?:\s|>)/i.test(svg)) return "not an SVG document";
|
||||
if (/<script(?:\s|>)/i.test(svg)) return "script elements are not allowed";
|
||||
if (/<foreignObject(?:\s|>)/i.test(svg)) return "foreignObject elements are not allowed";
|
||||
if (/<style(?:\s|>)/i.test(svg)) return "style elements are not allowed";
|
||||
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) return "event-handler attributes are not allowed";
|
||||
if (/javascript\s*:/i.test(svg)) return "javascript URLs are not allowed";
|
||||
for (const match of svg.matchAll(EXTERNAL_HREF)) {
|
||||
const href = match[2] ?? match[3] ?? "";
|
||||
if (!href.startsWith("#")) return "external href references are not allowed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -20,3 +20,10 @@ export {
|
||||
type PluginAttachmentSearchPayload,
|
||||
} from "./attachments.js";
|
||||
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
|
||||
export {
|
||||
findSvgIconProblem,
|
||||
PLUGIN_SVG_ICON_MAX_BYTES,
|
||||
type PluginIconProps,
|
||||
type PluginSerializableIcon,
|
||||
type PluginSvgIcon,
|
||||
} from "./icons.js";
|
||||
|
||||
@@ -534,6 +534,25 @@ export type Value = string;`,
|
||||
expect(clientBundle).not.toContain("React.createElement");
|
||||
});
|
||||
|
||||
it("imports an SVG file from a runtime directory as its markup", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
await writeFile(path.join(entries.directory, "client", "icon.svg"), svg);
|
||||
await writeFile(
|
||||
entries.client,
|
||||
`import icon from "./client/icon.svg";
|
||||
export default function contribute(client) {
|
||||
client.addCommandCenterItem({ id: "open", title: "Open", icon: { svg: icon }, context: "global", onSelect() {} });
|
||||
return () => undefined;
|
||||
}`,
|
||||
);
|
||||
const { clientBundle } = await compilePlugin(entries);
|
||||
const registered: unknown[] = [];
|
||||
const exports = new Function(`return ${clientBundle}`)()(() => undefined);
|
||||
exports.default({ addCommandCenterItem: (item: unknown) => registered.push(item) });
|
||||
expect(registered).toMatchObject([{ icon: { svg } }]);
|
||||
});
|
||||
|
||||
it("lowers async callbacks before Hermes evaluates the client bundle", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
await writeFile(
|
||||
|
||||
@@ -393,6 +393,8 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
jsx: "automatic",
|
||||
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
|
||||
loader: { ".svg": "text" },
|
||||
platform: target === "server" ? "node" : "neutral",
|
||||
target: target === "server" ? "node20" : "es2020",
|
||||
// The neutral platform reads no package.json entry fields, so packages without
|
||||
|
||||
@@ -29,18 +29,14 @@ describe("readPluginProviderIcon", () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
["non-SVG document", "hello", "file is not an SVG document"],
|
||||
["script", "<svg><script /></svg>", "script elements"],
|
||||
["foreign object", "<svg><foreignObject /></svg>", "foreignObject elements"],
|
||||
["style", "<svg><style /></svg>", "style elements"],
|
||||
["event handler", '<svg><path onclick="run()" /></svg>', "event-handler attributes"],
|
||||
["javascript URL", '<svg><a href="javascript:run()" /></svg>', "javascript URLs"],
|
||||
["non-SVG document", "hello", "not an SVG document"],
|
||||
["script", "<svg><script /></svg>", "script elements are not allowed"],
|
||||
["external href", '<svg><use href="https://example.com/icon.svg" /></svg>', "external href"],
|
||||
])("rejects %s", async (_name, svg, reason) => {
|
||||
])("rejects %s with the shared SVG rules", async (_name, svg, reason) => {
|
||||
const directory = await writeIcon(svg);
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
|
||||
`icon.svg": ${reason}`,
|
||||
`Invalid plugin provider icon "icon.svg": ${reason}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,7 +74,7 @@ describe("readPluginProviderIcon", () => {
|
||||
const directory = await writeIcon(`<svg>${" ".repeat(64 * 1024)}</svg>`);
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
|
||||
"file exceeds 64 KiB",
|
||||
`Invalid plugin provider icon "icon.svg": exceeds 64 KiB`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const MAX_ICON_BYTES = 64 * 1024;
|
||||
import { findSvgIconProblem } from "@getpaseo/plugin";
|
||||
|
||||
export async function readPluginProviderIcon(
|
||||
pluginDirectory: string,
|
||||
@@ -20,10 +19,10 @@ export async function readPluginProviderIcon(
|
||||
if (!iconStat.isFile()) {
|
||||
throw iconError(iconPath, "file does not exist or is not a regular file");
|
||||
}
|
||||
if (iconStat.size > MAX_ICON_BYTES) throw iconError(iconPath, "file exceeds 64 KiB");
|
||||
|
||||
const svg = await readFile(resolvedIcon, "utf8");
|
||||
validateSvg(iconPath, svg);
|
||||
const problem = findSvgIconProblem(svg);
|
||||
if (problem) throw iconError(iconPath, problem);
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -38,27 +37,6 @@ function assertInsidePluginDirectory(
|
||||
if (escapesDirectory) throw iconError(iconPath, "path leaves the plugin directory");
|
||||
}
|
||||
|
||||
function validateSvg(iconPath: string, svg: string): void {
|
||||
if (!/^\s*<svg(?:\s|>)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document");
|
||||
if (/<script(?:\s|>)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed");
|
||||
if (/<foreignObject(?:\s|>)/i.test(svg)) {
|
||||
throw iconError(iconPath, "foreignObject elements are not allowed");
|
||||
}
|
||||
if (/<style(?:\s|>)/i.test(svg)) throw iconError(iconPath, "style elements are not allowed");
|
||||
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) {
|
||||
throw iconError(iconPath, "event-handler attributes are not allowed");
|
||||
}
|
||||
if (/javascript\s*:/i.test(svg)) throw iconError(iconPath, "javascript URLs are not allowed");
|
||||
|
||||
const hrefPattern = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
|
||||
for (const match of svg.matchAll(hrefPattern)) {
|
||||
const href = match[2] ?? match[3] ?? "";
|
||||
if (!href.startsWith("#")) {
|
||||
throw iconError(iconPath, "external href references are not allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function iconError(iconPath: string, reason: string): Error {
|
||||
return new Error(`Invalid plugin provider icon "${iconPath}": ${reason}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user