feat: let plugins add buttons, calendar day labels and pages, and use connected accounts from the sandbox

This commit is contained in:
Linus Rath
2026-10-14 04:31:43 +02:00
parent 3a83c00e00
commit 5d9268c923
13 changed files with 1105 additions and 6 deletions
+3 -1
View File
@@ -71,7 +71,9 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
if (error instanceof ProxyError) {
const headers: Record<string, string> = { 'cache-control': 'no-store' };
// Marks the proxy's own refusals, so the plugin can tell them from the
// upstream server's errors.
const headers: Record<string, string> = { 'cache-control': 'no-store', 'x-bulwark-proxy-error': error.code };
const retryAfter = (error as ProxyError & { retryAfter?: number }).retryAfter;
if (retryAfter) headers['retry-after'] = String(retryAfter);
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status, headers });
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { PluginActions } from '../plugin-actions';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
const dispatch = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
resetActions();
dispatch.mockClear();
setActionDispatcher(dispatch);
});
describe('PluginActions', () => {
it('renders nothing when no plugin declared actions', () => {
const { container } = render(<PluginActions surface="message-bulk-actions" context={{ emailIds: ['e1'] }} />);
expect(container).toBeEmptyDOMElement();
});
it('draws declared buttons and sends the click with the context', async () => {
render(<PluginActions surface="message-bulk-actions" context={{ emailIds: ['e1', 'e2'] }} />);
act(() => setPluginActions('tasks', 'Email to task', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); });
expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['e1', 'e2'] });
});
it('shows only actions meant for the folder or file type', () => {
act(() => setPluginActions('p', 'P', 'attachment-menu', [
{ id: 'pdf', label: 'Send PDF', mimeTypes: ['application/pdf'] },
{ id: 'img', label: 'Edit image', mimeTypes: ['image/'] },
]));
render(<PluginActions surface="attachment-menu" context={{}} variant="menu" mimeType="image/png" />);
expect(screen.queryByRole('menuitem', { name: 'Send PDF' })).toBeNull();
expect(screen.getByRole('menuitem', { name: 'Edit image' })).toBeInTheDocument();
});
});
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useSyncExternalStore, useState } from 'react';
import { iconForName, Puzzle } from '@/components/icons';
import { ContextMenuItem } from '@/components/ui/context-menu';
import { cn } from '@/lib/utils';
import {
actionApplies,
actionsFor,
runAction,
subscribeActions,
type RegisteredAction,
} from '@/lib/plugin-sandbox/actions';
const EMPTY: RegisteredAction[] = [];
/** The actions plugins declared for a surface, filtered by folder role or MIME type. */
export function usePluginActions(surface: string, filter: { mailboxRole?: string | null; mimeType?: string | null } = {}) {
const all = useSyncExternalStore(subscribeActions, () => actionsFor(surface), () => EMPTY);
return all.filter((a) => actionApplies(a, filter));
}
interface PluginActionsProps {
/** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
surface: string;
/** What the click is about (ids of the message, files, ...). Filtered per plugin before it is sent. */
context: Record<string, unknown>;
/** Buttons in a toolbar row, or entries in a menu. */
variant?: 'buttons' | 'menu';
mailboxRole?: string | null;
mimeType?: string | null;
className?: string;
/** Icon-only buttons with the label as tooltip (narrow toolbars). */
compact?: boolean;
/** Called after a click, e.g. to close the menu the entry sits in. */
onRan?: () => void;
}
/**
* Buttons or menu entries plugins declared with api.ui.setActions. The host
* draws them so they match the app; the plugin gets the click.
*/
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, onRan }: PluginActionsProps) {
const actions = usePluginActions(surface, { mailboxRole, mimeType });
const [busy, setBusy] = useState<string | null>(null);
if (actions.length === 0) return null;
const run = async (action: RegisteredAction) => {
const key = `${action.pluginId}:${action.id}`;
setBusy(key);
try {
onRan?.();
await runAction(action, surface, context);
} finally {
setBusy((current) => (current === key ? null : current));
}
};
if (variant === 'menu') {
return (
<>
{actions.map((action) => (
<ContextMenuItem
key={`${action.pluginId}:${action.id}`}
icon={iconForName(action.icon) ?? Puzzle}
label={action.label}
title={action.pluginName}
destructive={action.danger}
onClick={() => { void run(action); }}
/>
))}
</>
);
}
return (
<div className={cn('inline-flex flex-wrap items-center gap-1', className)} data-plugin-actions={surface}>
{actions.map((action) => {
const Icon = iconForName(action.icon) ?? Puzzle;
const key = `${action.pluginId}:${action.id}`;
return (
<button
key={key}
type="button"
disabled={busy === key}
title={compact ? `${action.label} (${action.pluginName})` : action.pluginName}
aria-label={action.label}
onClick={(e) => { e.stopPropagation(); void run(action); }}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors',
'hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'disabled:opacity-50',
action.danger && 'text-destructive hover:bg-destructive/10 hover:text-destructive',
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{!compact && <span className="whitespace-nowrap">{action.label}</span>}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,146 @@
import { beforeEach, describe, it, expect, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
const apiFetch = vi.hoisted(() => vi.fn());
vi.mock('@/lib/browser-navigation', async (orig) => ({
...(await orig<typeof import('@/lib/browser-navigation')>()),
apiFetch: (...args: unknown[]) => apiFetch(...args),
}));
const registryEntries = vi.hoisted(() => [] as unknown[]);
vi.mock('../plugin-sandbox/registry', async (orig) => ({
...(await orig<typeof import('../plugin-sandbox/registry')>()),
all: () => registryEntries,
}));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
import { actionsFor, resetActions, runAction, checkActions } from '../plugin-sandbox/actions';
import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels';
import { allPages, resetPages } from '../plugin-sandbox/pages';
import type { StreamSink } from '../plugin-sandbox/proxy-client';
import '../plugin-sandbox/loader';
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook',
permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true,
...overrides,
};
}
beforeEach(() => {
apiFetch.mockReset();
resetActions();
resetDayLabels();
resetPages();
registryEntries.length = 0;
});
describe('ui.setActions', () => {
it('puts a plugin\'s buttons on a surface it may use', async () => {
await dispatchApiCall(plugin({ permissions: ['ui:message-list'] }), 'ui.setActions', ['message-row-actions', [
{ id: 'b', label: 'Second', order: 2 }, { id: 'a', label: 'First', order: 1, icon: 'archive' },
]]);
expect(actionsFor('message-row-actions').map((a) => [a.pluginId, a.id])).toEqual([['p1', 'a'], ['p1', 'b']]);
});
it('refuses a surface the plugin has no permission for, and frame slots', async () => {
await expect(dispatchApiCall(plugin({ permissions: ['ui:toolbar'] }), 'ui.setActions', ['message-row-actions', []]))
.rejects.toThrow(/may not add actions/);
await expect(dispatchApiCall(plugin({ permissions: ['ui:email-banner'] }), 'ui.setActions', ['email-banner', []]))
.rejects.toThrow(/not a surface that takes actions/);
expect(actionsFor('message-row-actions')).toEqual([]);
});
it('checks what it is given', () => {
expect(() => checkActions([{ id: 'x', label: '' }])).toThrow(/label/);
expect(() => checkActions([{ id: 'x', label: 'X' }, { id: 'x', label: 'Y' }])).toThrow(/twice/);
expect(() => checkActions(Array.from({ length: 9 }, (_, i) => ({ id: `a${i}`, label: 'A' })))).toThrow(/At most/);
expect(() => checkActions([{ id: 'x', label: 'X', icon: '<svg>' }])).toThrow(/icon/);
});
it('sends a click to the plugin with only the context it may see', async () => {
const invokeHook = vi.fn().mockResolvedValue(undefined);
const p = plugin({ permissions: ['ui:message-list'] });
registryEntries.push({ plugin: p, background: { invokeHook } });
await dispatchApiCall(p, 'ui.setActions', ['message-row-actions', [{ id: 'task', label: 'Make a task' }]]);
await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 });
// emailId needs email:read, which this plugin lacks.
expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: {} }]);
p.permissions.push('email:read');
await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 });
expect(invokeHook).toHaveBeenLastCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: { emailId: 'e1' } }]);
});
});
describe('day labels and pages', () => {
it('stores labels by date, behind ui:calendar-action', async () => {
await expect(dispatchApiCall(plugin(), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday' }]])).rejects.toThrow(/lacks permission/);
await dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday', dot: true }]]);
expect(dayLabelsByDate().get('2026-12-24')).toEqual([expect.objectContaining({ text: 'Holiday', dot: true, pluginId: 'p1' })]);
await expect(dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '24.12.2026', text: 'x' }]])).rejects.toThrow(/YYYY-MM-DD/);
});
it('registers pages behind ui:app-page', async () => {
await dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'files', title: 'Nextcloud', icon: 'cloud' }]]);
expect(allPages()).toEqual([expect.objectContaining({ pluginId: 'p1', id: 'files', title: 'Nextcloud' })]);
await expect(dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'A B', title: 'x' }]])).rejects.toThrow(/id/);
});
});
describe('proxy.fetch', () => {
const proxied = plugin({
permissions: ['http:proxy'],
credentials: { cloud: { label: 'Nextcloud', scope: 'user', inject: { type: 'basic' } } },
});
it('sends the request to the server proxy and returns the answer', async () => {
apiFetch.mockResolvedValue(new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } }));
const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/ocs/v2.php/cloud/user', {
method: 'GET', headers: { 'OCS-APIRequest': 'true' }, responseType: 'json',
}]);
expect(res).toMatchObject({ status: 200, ok: true, body: { ok: 1 } });
const [url, init] = apiFetch.mock.calls[0];
expect(url).toBe('/api/plugin-proxy?plugin=p1&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2Focs%2Fv2.php%2Fcloud%2Fuser');
expect(init.headers).toMatchObject({ 'OCS-APIRequest': 'true', 'x-bulwark-method': 'GET' });
});
it('refuses a credential the manifest does not declare, and plugins without http:proxy', async () => {
await expect(dispatchApiCall(proxied, 'proxy.fetch', ['other', 'https://a.example', {}])).rejects.toThrow(/declares no credential/);
await expect(dispatchApiCall({ ...proxied, permissions: [] }, 'proxy.fetch', ['cloud', 'https://a.example', {}])).rejects.toThrow(/lacks permission/);
expect(apiFetch).not.toHaveBeenCalled();
});
it('tells the plugin when the proxy itself refused', async () => {
apiFetch.mockResolvedValue(new Response('{"error":"Too many requests","code":"rate_limited"}', {
status: 429, headers: { 'content-type': 'application/json', 'x-bulwark-proxy-error': 'rate_limited' },
}));
const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', {}]);
expect(res).toMatchObject({ status: 429, ok: false, proxyError: 'rate_limited', body: 'Too many requests' });
});
it('streams a large answer to the iframe in chunks', async () => {
const encoder = new TextEncoder();
apiFetch.mockResolvedValue(new Response(new ReadableStream({
start(c) { c.enqueue(encoder.encode('data: one\n\n')); c.enqueue(encoder.encode('data: two\n\n')); c.close(); },
}), { status: 200, headers: { 'content-type': 'text/event-stream' } }));
const sent: Parameters<StreamSink['sendStream']>[0][] = [];
const sink: StreamSink = { sendStream: (m) => { sent.push(m); } };
const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', { responseType: 'stream', streamId: 'abc123' }], { sink });
await vi.waitFor(() => expect(sent.at(-1)?.type).toBe('stream-end'));
expect(res).toMatchObject({ status: 200, ok: true });
const text = sent.filter((m) => m.type === 'stream-chunk').map((m) => new TextDecoder().decode((m as { chunk: ArrayBuffer }).chunk)).join('');
expect(text).toBe('data: one\n\ndata: two\n\n');
});
});
+158
View File
@@ -0,0 +1,158 @@
// Buttons and menu entries plugins declare as data (`api.ui.setActions`).
// The host draws them in its own style on the surface named in the slot
// catalogue (kind: 'action'); a click goes back to the plugin with the
// surface's context, filtered by the plugin's permissions.
import { slotSpec } from '../plugin-slot-catalog';
export interface PluginAction {
id: string;
label: string;
/** Name of a host icon (see components/icons); unknown names show none. */
icon?: string;
/** Lower comes first; default 100. */
order?: number;
/** Show only for these folder roles (message surfaces), e.g. ["inbox"]. */
mailboxRoles?: string[];
/** Show only for attachments or files of these MIME types (prefix match, "image/"). */
mimeTypes?: string[];
/** A danger action is drawn in red. */
danger?: boolean;
}
export interface RegisteredAction extends PluginAction {
pluginId: string;
pluginName: string;
}
export const MAX_ACTIONS_PER_SURFACE = 8;
const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i;
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
const ROLE_RE = /^[a-z]{1,20}$/;
const MIME_RE = /^[a-z0-9.+-]+\/[a-z0-9.+*-]*$/i;
/** Surfaces that take actions, from the slot catalogue. */
export function isActionSurface(surface: string): boolean {
return slotSpec(surface)?.kind === 'action';
}
/** Check and copy what a plugin sent; throws with a message the plugin sees. */
export function checkActions(value: unknown): PluginAction[] {
if (!Array.isArray(value)) throw new Error('actions must be a list');
if (value.length > MAX_ACTIONS_PER_SURFACE) throw new Error(`At most ${MAX_ACTIONS_PER_SURFACE} actions per surface`);
const seen = new Set<string>();
return value.map((raw, i) => {
if (!raw || typeof raw !== 'object') throw new Error(`actions[${i}] must be an object`);
const a = raw as Record<string, unknown>;
if (typeof a.id !== 'string' || !ID_RE.test(a.id)) throw new Error(`actions[${i}].id is invalid`);
if (seen.has(a.id)) throw new Error(`actions[${i}].id is used twice`);
seen.add(a.id);
if (typeof a.label !== 'string' || !a.label.trim() || a.label.length > 60) throw new Error(`actions[${i}].label is required (max 60 characters)`);
const out: PluginAction = { id: a.id, label: a.label.trim() };
if (a.icon !== undefined) {
if (typeof a.icon !== 'string' || !ICON_RE.test(a.icon)) throw new Error(`actions[${i}].icon is invalid`);
out.icon = a.icon;
}
if (a.order !== undefined) {
if (typeof a.order !== 'number' || !Number.isFinite(a.order)) throw new Error(`actions[${i}].order must be a number`);
out.order = a.order;
}
if (a.mailboxRoles !== undefined) {
if (!Array.isArray(a.mailboxRoles) || a.mailboxRoles.length > 10 || !a.mailboxRoles.every((r) => typeof r === 'string' && ROLE_RE.test(r))) {
throw new Error(`actions[${i}].mailboxRoles must be a list of folder roles`);
}
out.mailboxRoles = [...a.mailboxRoles] as string[];
}
if (a.mimeTypes !== undefined) {
if (!Array.isArray(a.mimeTypes) || a.mimeTypes.length > 20 || !a.mimeTypes.every((m) => typeof m === 'string' && MIME_RE.test(m))) {
throw new Error(`actions[${i}].mimeTypes must be a list of MIME types`);
}
out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase());
}
if (a.danger !== undefined) out.danger = a.danger === true;
return out;
});
}
// pluginId → surface → actions
const registry = new Map<string, Map<string, RegisteredAction[]>>();
const listeners = new Set<() => void>();
const snapshots = new Map<string, RegisteredAction[]>();
function changed(): void {
snapshots.clear();
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
export function subscribeActions(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function setPluginActions(pluginId: string, pluginName: string, surface: string, actions: PluginAction[]): void {
let surfaces = registry.get(pluginId);
if (!surfaces) {
surfaces = new Map();
registry.set(pluginId, surfaces);
}
if (actions.length === 0) surfaces.delete(surface);
else surfaces.set(surface, actions.map((a) => ({ ...a, pluginId, pluginName })));
changed();
}
/** Drop everything a plugin declared (it was turned off or removed). */
export function clearPluginActions(pluginId: string): void {
if (registry.delete(pluginId)) changed();
}
/**
* The actions on a surface, in order. The same array is returned until
* something changes, so it can feed useSyncExternalStore.
*/
export function actionsFor(surface: string): RegisteredAction[] {
const cached = snapshots.get(surface);
if (cached) return cached;
const out: RegisteredAction[] = [];
for (const surfaces of registry.values()) out.push(...(surfaces.get(surface) ?? []));
out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
snapshots.set(surface, out);
return out;
}
/** Whether an action applies to a folder role and/or a MIME type. */
export function actionApplies(action: PluginAction, ctx: { mailboxRole?: string | null; mimeType?: string | null }): boolean {
if (action.mailboxRoles && ctx.mailboxRole !== undefined && !action.mailboxRoles.includes(ctx.mailboxRole ?? '')) return false;
if (action.mimeTypes && ctx.mimeType !== undefined) {
const type = (ctx.mimeType ?? '').toLowerCase();
if (!action.mimeTypes.some((m) => (m.endsWith('/') || m.endsWith('/*') ? type.startsWith(m.replace(/\*$/, '')) : type === m))) return false;
}
return true;
}
/** Test helper. */
export function resetActions(): void {
registry.clear();
changed();
}
// ─── Clicks ───────────────────────────────────────────────────
/**
* Delivers a click to the plugin. Set by the loader, which owns the running
* plugins; it filters `context` by the surface's props and the plugin's
* permissions before anything crosses into the sandbox.
*/
export type ActionDispatcher = (pluginId: string, surface: string, actionId: string, context: Record<string, unknown>) => Promise<void>;
let dispatcher: ActionDispatcher | null = null;
export function setActionDispatcher(next: ActionDispatcher | null): void {
dispatcher = next;
}
export async function runAction(action: RegisteredAction, surface: string, context: Record<string, unknown>): Promise<void> {
if (!dispatcher) return;
await dispatcher(action.pluginId, surface, action.id, context);
}
+91
View File
@@ -0,0 +1,91 @@
// Short labels and dots plugins put on calendar days (`api.ui.setDayLabels`):
// a holiday name under the day header, a dot in the small month calendar.
// Data only; the host draws them.
export interface DayLabel {
/** YYYY-MM-DD */
date: string;
/** Shown under the day's name in the day and week views (max 24 characters). */
text?: string;
/** Tooltip (max 120 characters). */
title?: string;
/** A dot on the day in the small month calendar. */
dot?: boolean;
}
export interface RegisteredDayLabel extends DayLabel {
pluginId: string;
pluginName: string;
}
export const MAX_DAY_LABELS = 400;
const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
export function checkDayLabels(value: unknown): DayLabel[] {
if (!Array.isArray(value)) throw new Error('labels must be a list');
if (value.length > MAX_DAY_LABELS) throw new Error(`At most ${MAX_DAY_LABELS} day labels`);
return value.map((raw, i) => {
if (!raw || typeof raw !== 'object') throw new Error(`labels[${i}] must be an object`);
const l = raw as Record<string, unknown>;
if (typeof l.date !== 'string' || !DATE_RE.test(l.date)) throw new Error(`labels[${i}].date must be YYYY-MM-DD`);
const out: DayLabel = { date: l.date };
if (l.text !== undefined) {
if (typeof l.text !== 'string' || l.text.length > 24) throw new Error(`labels[${i}].text must be at most 24 characters`);
if (l.text.trim()) out.text = l.text.trim();
}
if (l.title !== undefined) {
if (typeof l.title !== 'string' || l.title.length > 120) throw new Error(`labels[${i}].title must be at most 120 characters`);
if (l.title.trim()) out.title = l.title.trim();
}
if (l.dot !== undefined) out.dot = l.dot === true;
if (!out.text && !out.dot) throw new Error(`labels[${i}] needs text or dot`);
return out;
});
}
const byPlugin = new Map<string, RegisteredDayLabel[]>();
const listeners = new Set<() => void>();
let snapshot: Map<string, RegisteredDayLabel[]> | null = null;
function changed(): void {
snapshot = null;
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
export function subscribeDayLabels(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function setDayLabels(pluginId: string, pluginName: string, labels: DayLabel[]): void {
if (labels.length === 0) byPlugin.delete(pluginId);
else byPlugin.set(pluginId, labels.map((l) => ({ ...l, pluginId, pluginName })));
changed();
}
export function clearDayLabels(pluginId: string): void {
if (byPlugin.delete(pluginId)) changed();
}
/** Labels by date. Stable between changes (for useSyncExternalStore). */
export function dayLabelsByDate(): Map<string, RegisteredDayLabel[]> {
if (snapshot) return snapshot;
const out = new Map<string, RegisteredDayLabel[]>();
for (const labels of byPlugin.values()) {
for (const l of labels) {
const list = out.get(l.date);
if (list) list.push(l);
else out.set(l.date, [l]);
}
}
snapshot = out;
return out;
}
/** Test helper. */
export function resetDayLabels(): void {
byPlugin.clear();
changed();
}
+33 -2
View File
@@ -3,7 +3,11 @@
// structured-cloneable data back to the iframe.
import type { InstalledPlugin, Permission } from '../plugin-types';
import { pluginHasPermission } from './permissions';
import { mayOfferSlot, pluginHasPermission } from './permissions';
import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch, type StreamSink } from './proxy-client';
import { checkActions, isActionSurface, setPluginActions } from './actions';
import { checkDayLabels, setDayLabels } from './day-labels';
import { checkPages, setPluginPages } from './pages';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
@@ -169,6 +173,17 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
// redirect URI the provider sends the user back to.
'oauth.createState': 'http:fetch',
'oauth.getRedirectUri': 'http:fetch',
// Requests with a credential, through the server (lib/plugin-secrets).
'proxy.fetch': 'http:proxy',
'proxy.cancel': 'http:proxy',
'proxy.connect': 'http:proxy',
'proxy.accounts': 'http:proxy',
'proxy.disconnect': 'http:proxy',
// Buttons and menu entries drawn by the host; the surface's own
// permission is checked per call (see the slot catalogue).
'ui.setActions': null,
'ui.setDayLabels': 'ui:calendar-action',
'ui.setPages': 'ui:app-page',
// sieve (delivery-time classification)
'sieve.isSupported': 'filters:read',
'sieve.getActiveScript': 'filters:read',
@@ -1576,7 +1591,7 @@ export async function dispatchApiCall(
plugin: InstalledPlugin,
method: string,
args: unknown[],
opts?: { privileged?: boolean },
opts?: { privileged?: boolean; sink?: StreamSink },
): Promise<unknown> {
// Tier gate: privileged-only methods are refused for untrusted (null-origin)
// instances even if the permission is somehow present. Defence-in-depth on
@@ -1596,6 +1611,22 @@ export async function dispatchApiCall(
}
switch (method) {
case 'proxy.fetch': return proxyFetch(plugin, args, opts?.sink ?? null);
case 'proxy.cancel': proxyCancel(plugin, args[0]); return undefined;
case 'proxy.connect': return proxyConnect(plugin, args);
case 'proxy.accounts': return proxyAccounts(plugin);
case 'proxy.disconnect': return proxyDisconnect(plugin, args);
case 'ui.setActions': {
const surface = args[0];
if (typeof surface !== 'string' || !isActionSurface(surface)) throw new Error(`"${String(surface)}" is not a surface that takes actions`);
if (!mayOfferSlot(plugin, surface)) throw new Error(`Plugin "${plugin.id}" may not add actions to "${surface}"`);
setPluginActions(plugin.id, plugin.name, surface, checkActions(args[1]));
return undefined;
}
case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined;
case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined;
case 'storage.get': return storageGet(plugin.id, args[0] as string);
case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined;
case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined;
+13 -2
View File
@@ -15,7 +15,7 @@ import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
import { effectiveSettings } from '../plugin-settings';
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg,
} from './protocol';
// ─── Callback marshalling ────────────────────────────────────
@@ -249,7 +249,7 @@ export class SandboxInstance {
const { id, method, args } = msg;
void (async () => {
try {
const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged });
const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged, sink: this });
this.send({ type: 'api-response', id, ok: true, result });
} catch (err) {
this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
@@ -353,6 +353,17 @@ export class SandboxInstance {
return p;
}
/** Streamed proxy responses and upload progress (see proxy-client.ts). */
sendStream(msg: StreamChunkMsg | StreamEndMsg | StreamProgressMsg): void {
if (this.destroyed) return;
if (msg.type === 'stream-chunk') {
// Hand the buffer over instead of copying it.
this.iframe.contentWindow?.postMessage(msg, '*', [msg.chunk]);
return;
}
this.send(msg);
}
setLocale(locale: string): void {
if (this.destroyed) return;
this.send({ type: 'locale-change', locale });
+33 -1
View File
@@ -21,7 +21,13 @@ import { resolvePluginTier } from './tier';
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
import { cancelPluginDialogs } from './host-api';
import { registerShortcuts } from './shortcuts';
import { mayOfferSlot, mayRegisterHook, pluginHasPermission } from './permissions';
import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions';
import { clearPluginActions, setActionDispatcher } from './actions';
import { clearDayLabels } from './day-labels';
import { clearPluginPages } from './pages';
import { cancelPluginProxyRequests } from './proxy-client';
import { cancelConnectFor } from './connect-dialog';
import type { SlotName } from '../plugin-types';
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
@@ -182,6 +188,9 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
const hookDisposables: Disposable[] = [];
for (const hookName of info.hooks) {
if (hookName.startsWith('shortcut:')) continue;
// Not a bus: the host calls it directly for clicks on this plugin's own
// actions (see setActionDispatcher below).
if (hookName === 'onAction') continue;
const bus = HOOK_BUSES[hookName];
if (!bus) {
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
@@ -237,6 +246,22 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
}
}
// ─── Action clicks ────────────────────────────────────────────
// A click on a button a plugin declared (api.ui.setActions) goes to that
// plugin's background instance as its `onAction` hook, with the surface's
// context cut down to what the plugin may see.
setActionDispatcher(async (pluginId, surface, actionId, context) => {
const entry = allActiveEntries().find((e) => e.plugin.id === pluginId);
if (!entry) return;
const visible = slotPropsFor(entry.plugin, surface as SlotName, context);
try {
await entry.background.invokeHook('onAction', [{ surface, actionId, context: visible }]);
} catch (err) {
pluginErrorTracker.record(pluginId, err);
}
});
// ─── Unload ───────────────────────────────────────────────────
export function unloadSandboxedPlugin(pluginId: string): void {
@@ -249,6 +274,13 @@ export function unloadSandboxedPlugin(pluginId: string): void {
// Drop any message-list category tabs the plugin registered so the strip
// disappears (and the inbox unfilters) the moment the plugin is disabled.
try { useMessageListTabsStore.getState().clearTabs(pluginId); } catch { /* ignore */ }
// Same for the buttons, day labels and pages it declared, and anything
// still running through the proxy for it.
clearPluginActions(pluginId);
clearDayLabels(pluginId);
clearPluginPages(pluginId);
cancelPluginProxyRequests(pluginId);
cancelConnectFor(pluginId);
try { entry.background.destroy(); } catch { /* ignore */ }
cancelPluginDialogs(pluginId);
pluginErrorTracker.reset(pluginId);
+82
View File
@@ -0,0 +1,82 @@
// Full pages plugins add to the app (`api.ui.setPages`): each gets an entry
// in the navigation rail and its own route, /apps/<pluginId>/<pageId>, where
// the plugin's `app-page` slot fills the content area.
export interface PluginPage {
id: string;
title: string;
/** Name of a host icon (see components/icons). */
icon?: string;
}
export interface RegisteredPage extends PluginPage {
pluginId: string;
pluginName: string;
}
export const MAX_PAGES_PER_PLUGIN = 3;
const ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
export function checkPages(value: unknown): PluginPage[] {
if (!Array.isArray(value)) throw new Error('pages must be a list');
if (value.length > MAX_PAGES_PER_PLUGIN) throw new Error(`At most ${MAX_PAGES_PER_PLUGIN} pages`);
const seen = new Set<string>();
return value.map((raw, i) => {
if (!raw || typeof raw !== 'object') throw new Error(`pages[${i}] must be an object`);
const p = raw as Record<string, unknown>;
if (typeof p.id !== 'string' || !ID_RE.test(p.id)) throw new Error(`pages[${i}].id must be lowercase letters, digits and hyphens`);
if (seen.has(p.id)) throw new Error(`pages[${i}].id is used twice`);
seen.add(p.id);
if (typeof p.title !== 'string' || !p.title.trim() || p.title.length > 40) throw new Error(`pages[${i}].title is required (max 40 characters)`);
const out: PluginPage = { id: p.id, title: p.title.trim() };
if (p.icon !== undefined) {
if (typeof p.icon !== 'string' || !ICON_RE.test(p.icon)) throw new Error(`pages[${i}].icon is invalid`);
out.icon = p.icon;
}
return out;
});
}
const byPlugin = new Map<string, RegisteredPage[]>();
const listeners = new Set<() => void>();
let snapshot: RegisteredPage[] | null = null;
function changed(): void {
snapshot = null;
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
export function subscribePages(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function setPluginPages(pluginId: string, pluginName: string, pages: PluginPage[]): void {
if (pages.length === 0) byPlugin.delete(pluginId);
else byPlugin.set(pluginId, pages.map((p) => ({ ...p, pluginId, pluginName })));
changed();
}
export function clearPluginPages(pluginId: string): void {
if (byPlugin.delete(pluginId)) changed();
}
/** Every page, plugins in install order. Stable between changes. */
export function allPages(): RegisteredPage[] {
if (snapshot) return snapshot;
snapshot = [...byPlugin.values()].flat();
return snapshot;
}
export function findPage(pluginId: string, pageId: string): RegisteredPage | null {
return byPlugin.get(pluginId)?.find((p) => p.id === pageId) ?? null;
}
/** Test helper. */
export function resetPages(): void {
byPlugin.clear();
changed();
}
+10
View File
@@ -191,8 +191,18 @@ export interface SlotShouldShowMsg {
context: unknown;
}
/** Host → sandbox: a piece of a streamed api.proxy.fetch response. */
export interface StreamChunkMsg { type: 'stream-chunk'; streamId: string; chunk: ArrayBuffer; }
/** Host → sandbox: a streamed response ended (error set when it broke off). */
export interface StreamEndMsg { type: 'stream-end'; streamId: string; error?: string; }
/** Host → sandbox: upload progress of an api.proxy.fetch request. */
export interface StreamProgressMsg { type: 'stream-progress'; streamId: string; loaded: number; total: number; }
export type HostToSandbox =
| InitMsg
| StreamChunkMsg
| StreamEndMsg
| StreamProgressMsg
| ApiResponseMsg
| CallbackResponseMsg
| HookInvokeMsg
+292
View File
@@ -0,0 +1,292 @@
// Host side of `api.proxy`: a plugin's requests that need one of its
// credentials go to /api/plugin-proxy, which adds the secret on the server.
// Responses come back whole (text, JSON, bytes) or as a stream of chunks for
// large downloads and AI answers; uploads report progress.
import type { InstalledPlugin } from '../plugin-types';
import { apiFetch, withBasePath } from '../browser-navigation';
import { requestConnect, forgetCredentials, listConnectedAccounts } from './connect-dialog';
/** Where streamed chunks and upload progress go: the iframe that asked. */
export interface StreamSink {
sendStream(msg:
| { type: 'stream-chunk'; streamId: string; chunk: ArrayBuffer }
| { type: 'stream-end'; streamId: string; error?: string }
| { type: 'stream-progress'; streamId: string; loaded: number; total: number }): void;
}
/** Largest response returned in one piece; bigger ones need responseType "stream". */
export const MAX_PROXY_BUFFERED_BYTES = 25 * 1024 * 1024;
/** Largest request body; matches MAX_PROXY_REQUEST_BYTES on the server. */
export const MAX_PROXY_UPLOAD_BYTES = 100 * 1024 * 1024;
export const MAX_STREAMS_PER_PLUGIN = 8;
type ResponseType = 'text' | 'json' | 'arraybuffer' | 'stream';
export interface ProxyInit {
method?: string;
headers?: Record<string, string>;
body?: string | ArrayBuffer | Uint8Array | Blob | null;
responseType?: ResponseType;
/** Set by the runtime: ties chunks and progress to this request. */
streamId?: string;
/** Store these JSON fields of the response as the credential (see the proxy route). */
capture?: { secret: string; username?: string };
}
export interface ProxyResult {
status: number;
ok: boolean;
headers: Record<string, string>;
body?: unknown;
/** Set when the proxy itself refused, e.g. "rate_limited", "wrong_origin". */
proxyError?: string;
}
const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
const STREAM_ID_RE = /^[a-z0-9]{6,40}$/;
const running = new Map<string, Map<string, AbortController>>();
function credentialSpec(plugin: InstalledPlugin, credentialId: unknown) {
if (typeof credentialId !== 'string') throw new Error('credentialId must be a string');
const spec = plugin.credentials?.[credentialId];
if (!spec) throw new Error(`The manifest declares no credential "${credentialId}"`);
return { id: credentialId, spec };
}
function checkInit(raw: unknown): ProxyInit {
if (raw === undefined || raw === null) return {};
if (typeof raw !== 'object') throw new Error('init must be an object');
const init = raw as Record<string, unknown>;
const out: ProxyInit = {};
if (init.method !== undefined) {
if (typeof init.method !== 'string' || !/^[A-Z]{3,10}$/i.test(init.method)) throw new Error('Invalid method');
out.method = init.method.toUpperCase();
}
if (init.headers !== undefined) {
if (!init.headers || typeof init.headers !== 'object') throw new Error('headers must be an object');
const entries = Object.entries(init.headers as Record<string, unknown>);
if (entries.length > 50) throw new Error('Too many headers');
out.headers = {};
for (const [name, value] of entries) {
if (!HEADER_NAME_RE.test(name) || typeof value !== 'string' || value.length > 8192 || /[\r\n]/.test(value)) {
throw new Error(`Invalid header "${name}"`);
}
out.headers[name] = value;
}
}
if (init.body !== undefined && init.body !== null) {
const b = init.body;
const size = typeof b === 'string' ? b.length : b instanceof Blob ? b.size : (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) ? (b as ArrayBuffer).byteLength : -1;
if (size < 0) throw new Error('body must be a string, ArrayBuffer, Uint8Array or Blob');
if (size > MAX_PROXY_UPLOAD_BYTES) throw new Error(`body is larger than ${MAX_PROXY_UPLOAD_BYTES} bytes; upload it in chunks`);
out.body = b as ProxyInit['body'];
}
if (init.responseType !== undefined) {
if (!['text', 'json', 'arraybuffer', 'stream'].includes(init.responseType as string)) throw new Error('Invalid responseType');
out.responseType = init.responseType as ResponseType;
}
if (init.streamId !== undefined) {
if (typeof init.streamId !== 'string' || !STREAM_ID_RE.test(init.streamId)) throw new Error('Invalid streamId');
out.streamId = init.streamId;
}
if (init.capture !== undefined) {
const c = init.capture as Record<string, unknown>;
if (!c || typeof c.secret !== 'string' || (c.username !== undefined && typeof c.username !== 'string')) throw new Error('Invalid capture');
out.capture = { secret: c.secret, ...(typeof c.username === 'string' ? { username: c.username } : {}) };
}
return out;
}
function headersOf(res: { headers: Headers }): Record<string, string> {
const out: Record<string, string> = {};
res.headers.forEach((value, name) => { out[name] = value; });
return out;
}
async function readCapped(res: Response, limit: number): Promise<ArrayBuffer> {
const length = Number(res.headers.get('content-length') ?? '0');
if (length > limit) {
await res.body?.cancel().catch(() => {});
throw new Error(`Response is larger than ${limit} bytes; use responseType "stream"`);
}
if (!res.body) return new ArrayBuffer(0);
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > limit) {
await reader.cancel().catch(() => {});
throw new Error(`Response is larger than ${limit} bytes; use responseType "stream"`);
}
chunks.push(value);
}
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
return out.buffer;
}
function track(pluginId: string, streamId: string, controller: AbortController): () => void {
let streams = running.get(pluginId);
if (!streams) {
streams = new Map();
running.set(pluginId, streams);
}
if (streams.size >= MAX_STREAMS_PER_PLUGIN) throw new Error(`At most ${MAX_STREAMS_PER_PLUGIN} proxy requests at once`);
streams.set(streamId, controller);
return () => {
streams!.delete(streamId);
if (streams!.size === 0) running.delete(pluginId);
};
}
/** Upload with progress reports; resolves with a fetch-like Response. */
function sendWithProgress(url: string, method: string, headers: Record<string, string>, body: XMLHttpRequestBodyInit, signal: AbortSignal, onProgress: (loaded: number, total: number) => void): Promise<Response> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.responseType = 'arraybuffer';
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
xhr.setRequestHeader('x-bulwark-method', method);
xhr.upload.onprogress = (e) => onProgress(e.loaded, e.lengthComputable ? e.total : 0);
xhr.onload = () => {
const h = new Headers();
for (const line of xhr.getAllResponseHeaders().trim().split(/[\r\n]+/)) {
const i = line.indexOf(':');
if (i > 0) h.append(line.slice(0, i).trim(), line.slice(i + 1).trim());
}
resolve(new Response(xhr.status === 204 || xhr.status === 304 ? null : xhr.response as ArrayBuffer, { status: xhr.status, headers: h }));
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'));
signal.addEventListener('abort', () => xhr.abort(), { once: true });
xhr.send(body);
});
}
/** `api.proxy.fetch(credentialId, url, init)`. */
export async function proxyFetch(plugin: InstalledPlugin, args: unknown[], sink: StreamSink | null): Promise<ProxyResult> {
const { id: credentialId } = credentialSpec(plugin, args[0]);
if (typeof args[1] !== 'string' || args[1].length > 8192) throw new Error('url must be a string');
const target = args[1];
const init = checkInit(args[2]);
const method = init.method ?? (init.body ? 'POST' : 'GET');
const responseType = init.responseType ?? 'text';
const streamId = init.streamId ?? Math.random().toString(36).slice(2, 12);
if (init.capture && plugin.credentials?.[credentialId]?.scope !== 'user') throw new Error('capture needs a user credential');
const query = new URLSearchParams({ plugin: plugin.id, credential: credentialId, url: target });
const url = withBasePath(`/api/plugin-proxy?${query}`);
const headers: Record<string, string> = { ...(init.headers ?? {}) };
if (init.capture) headers['x-bulwark-capture'] = JSON.stringify(init.capture);
const controller = new AbortController();
const untrack = track(plugin.id, streamId, controller);
let streaming = false;
try {
let res: Response;
if (init.body && sink && typeof XMLHttpRequest !== 'undefined') {
res = await sendWithProgress(url, method, headers, init.body as XMLHttpRequestBodyInit, controller.signal,
(loaded, total) => sink.sendStream({ type: 'stream-progress', streamId, loaded, total }));
} else {
res = await apiFetch(`/api/plugin-proxy?${query}`, {
method: 'POST',
headers: { ...headers, 'x-bulwark-method': method },
body: (init.body ?? undefined) as BodyInit | undefined,
signal: controller.signal,
});
}
const result: ProxyResult = { status: res.status, ok: res.ok, headers: headersOf(res) };
const proxyRefused = !!res.headers.get('x-bulwark-proxy-error');
if (responseType === 'stream' && sink && res.body && res.ok) {
streaming = true;
const reader = res.body.getReader();
void (async () => {
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const copy = value.slice().buffer;
sink.sendStream({ type: 'stream-chunk', streamId, chunk: copy });
}
sink.sendStream({ type: 'stream-end', streamId });
} catch (err) {
sink.sendStream({ type: 'stream-end', streamId, error: controller.signal.aborted ? 'cancelled' : (err as Error).message });
} finally {
untrack();
}
})();
return result;
}
const bytes = await readCapped(res, MAX_PROXY_BUFFERED_BYTES);
if (proxyRefused) {
result.proxyError = res.headers.get('x-bulwark-proxy-error') ?? 'refused';
try {
result.body = (JSON.parse(new TextDecoder().decode(bytes)) as { error?: string }).error ?? '';
} catch {
result.body = '';
}
return result;
}
if (responseType === 'arraybuffer') result.body = bytes;
else {
const text = new TextDecoder().decode(bytes);
if (responseType === 'json') {
try { result.body = text ? JSON.parse(text) : null; } catch { throw new Error(`Response is not JSON (HTTP ${res.status})`); }
} else {
result.body = text;
}
}
return result;
} finally {
if (!streaming) untrack();
}
}
/** `api.proxy.cancel(streamId)`: stop a running request or stream. */
export function proxyCancel(plugin: InstalledPlugin, streamId: unknown): void {
if (typeof streamId !== 'string') return;
running.get(plugin.id)?.get(streamId)?.abort();
}
/** Stop everything a plugin has running (it was turned off). */
export function cancelPluginProxyRequests(pluginId: string): void {
for (const controller of running.get(pluginId)?.values() ?? []) controller.abort();
running.delete(pluginId);
}
/** `api.proxy.connect(credentialId, { mode, server, username })`. */
export async function proxyConnect(plugin: InstalledPlugin, args: unknown[]): Promise<{ connected: boolean; origin?: string }> {
const { id, spec } = credentialSpec(plugin, args[0]);
if (spec.scope !== 'user') throw new Error(`"${id}" is set up by the administrator`);
const opts = (args[1] && typeof args[1] === 'object' ? args[1] : {}) as Record<string, unknown>;
const mode = opts.mode === 'signin' ? 'signin' : 'password';
return requestConnect({
pluginId: plugin.id,
pluginName: plugin.name,
credentialId: id,
spec,
mode,
...(typeof opts.server === 'string' && opts.server.length < 2048 ? { suggestedOrigin: opts.server } : {}),
...(typeof opts.username === 'string' && opts.username.length < 256 ? { suggestedUsername: opts.username } : {}),
});
}
/** `api.proxy.accounts()`: which of its credentials are connected, without secrets. */
export async function proxyAccounts(plugin: InstalledPlugin): Promise<{ credentialId: string; origin: string; username?: string; connected: boolean }[]> {
const list = await listConnectedAccounts(plugin.id);
return list.map(({ credentialId, origin, username, connected }) => ({ credentialId, origin, ...(username ? { username } : {}), connected }));
}
/** `api.proxy.disconnect(credentialId)`. */
export async function proxyDisconnect(plugin: InstalledPlugin, args: unknown[]): Promise<boolean> {
const { id } = credentialSpec(plugin, args[0]);
return forgetCredentials(plugin.id, id);
}
+100
View File
@@ -190,6 +190,61 @@ function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_AP
});
}
// ─── api.proxy streams ───────────────────────────────────────
interface ProxyStream {
controller?: ReadableStreamDefaultController<Uint8Array>;
onProgress?: (loaded: number, total: number) => void;
}
const proxyStreams = new Map<string, ProxyStream>();
interface ProxyFetchInit {
method?: string;
headers?: Record<string, string>;
body?: string | ArrayBuffer | Uint8Array | Blob | null;
/** "text" (default), "json", "arraybuffer", or "stream" for a ReadableStream body. */
responseType?: 'text' | 'json' | 'arraybuffer' | 'stream';
/** Upload progress, in bytes. */
onProgress?: (loaded: number, total: number) => void;
/** Store these JSON fields of the response as the credential instead of returning them. */
capture?: { secret: string; username?: string };
/** Stops the request (and a streamed body). */
signal?: AbortSignal;
}
async function proxyFetch(credentialId: string, url: string, init: ProxyFetchInit = {}): Promise<unknown> {
const streamId = uid().replace(/[^a-z0-9]/g, '').slice(0, 24);
const entry: ProxyStream = { onProgress: init.onProgress };
proxyStreams.set(streamId, entry);
const stream = init.responseType === 'stream'
? new ReadableStream<Uint8Array>({
start(controller) { entry.controller = controller; },
cancel() {
proxyStreams.delete(streamId);
void callApi('proxy.cancel', [streamId]).catch(() => {});
},
})
: null;
const abort = () => {
void callApi('proxy.cancel', [streamId]).catch(() => {});
try { entry.controller?.error(new Error('cancelled')); } catch { /* closed */ }
proxyStreams.delete(streamId);
};
init.signal?.addEventListener('abort', abort, { once: true });
const { onProgress: _p, signal: _s, ...plain } = init;
void _p; void _s;
try {
// No overall timeout: a big upload or a long AI answer takes as long as
// it takes, and api.proxy.cancel / the signal stop it.
const res = await callApi('proxy.fetch', [credentialId, url, { ...plain, streamId }], 0) as Record<string, unknown>;
if (!stream || !res.ok) proxyStreams.delete(streamId);
return stream && res.ok ? { ...res, body: stream } : res;
} catch (err) {
proxyStreams.delete(streamId);
throw err;
}
}
function invokeHostCallback(callbackId: string, args: unknown[]): Promise<unknown> {
const id = uid();
return new Promise((resolve, reject) => {
@@ -421,6 +476,31 @@ function buildPluginApi(manifest: PluginManifest) {
/** Downloads a file generated by the plugin. Not a user's file or attachment. */
downloadFile: (opts: { content: string; filename: string; contentType?: string }) =>
callApi('ui.downloadFile', [opts]) as Promise<void>,
/**
* Buttons or menu entries on an action surface (see the slot catalogue:
* message rows, the selection toolbar, attachment menus, the command
* palette, ...). Replaces what this plugin set there before; [] removes
* them. Clicks arrive in the `onAction({ surface, actionId, context })` hook.
*/
setActions: (surface: string, actions: Array<{ id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean }>) =>
callApi('ui.setActions', [surface, actions]) as Promise<void>,
/** Short labels and dots on calendar days. Replaces this plugin's earlier labels. */
setDayLabels: (labels: Array<{ date: string; text?: string; title?: string; dot?: boolean }>) =>
callApi('ui.setDayLabels', [labels]) as Promise<void>,
/** Full pages with a button in the navigation bar; the `app-page` slot renders them. */
setPages: (pages: Array<{ id: string; title: string; icon?: string }>) =>
callApi('ui.setPages', [pages]) as Promise<void>,
},
// Requests with an account or key the manifest declares under
// `credentials`; the server adds the secret (permission http:proxy).
proxy: {
fetch: proxyFetch,
/** Show the host's dialog to connect a user credential. */
connect: (credentialId: string, opts?: { mode?: 'password' | 'signin'; server?: string; username?: string }) =>
callApi('proxy.connect', [credentialId, opts ?? {}], 0) as Promise<{ connected: boolean; origin?: string }>,
/** Which credentials are connected (no secrets). */
accounts: () => callApi('proxy.accounts', []) as Promise<Array<{ credentialId: string; origin: string; username?: string; connected: boolean }>>,
disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise<boolean>,
},
// Email keyword mutations (permission: email:write). Keywords follow JMAP
// syntax, e.g. '$category-promotions' or '$label:<tagId>'.
@@ -840,6 +920,26 @@ function handleHostMessage(ev: MessageEvent): void {
case 'props-update':
slotPropsUpdater?.(msg.props ?? {});
break;
case 'stream-chunk': {
const entry = proxyStreams.get(msg.streamId);
try { entry?.controller?.enqueue(new Uint8Array(msg.chunk)); } catch { /* cancelled */ }
break;
}
case 'stream-end': {
const entry = proxyStreams.get(msg.streamId);
proxyStreams.delete(msg.streamId);
try {
if (msg.error) entry?.controller?.error(new Error(msg.error));
else entry?.controller?.close();
} catch { /* already closed */ }
break;
}
case 'stream-progress':
try { proxyStreams.get(msg.streamId)?.onProgress?.(msg.loaded, msg.total); } catch { /* plugin's own error */ }
break;
}
}