feat: harden plugin sandbox and migrate in-tree plugins
This commit is contained in:
@@ -6,6 +6,8 @@ import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-p
|
||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -35,6 +37,8 @@ export default async function LocaleLayout({
|
||||
<TourProvider>
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
// Modal shown the first time a plugin is enabled, listing every permission
|
||||
// the plugin's manifest declares. Accepting persists the grant on the
|
||||
// plugin record so future enables skip the prompt.
|
||||
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { head, resolveHead, subscribe, describePermission } from '@/lib/plugin-sandbox/consent';
|
||||
|
||||
export function PluginConsentDialog(): React.JSX.Element | null {
|
||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
resolveHead(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => document.removeEventListener('keydown', onKey, true);
|
||||
}, [current]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="plugin-consent-title"
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 100001,
|
||||
}}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) resolveHead(false); }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--background, #fff)',
|
||||
color: 'var(--foreground, #0f172a)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 560,
|
||||
width: '92%',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
||||
}}>
|
||||
<h2 id="plugin-consent-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 6px 0' }}>
|
||||
Allow “{current.pluginName}” to access your data?
|
||||
</h2>
|
||||
<p style={{ fontSize: 12, color: 'var(--muted-foreground, #64748b)', margin: '0 0 14px 0' }}>
|
||||
This plugin is requesting the permissions below. You can revoke them by uninstalling the plugin.
|
||||
</p>
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 16px 0', maxHeight: 320, overflowY: 'auto' }}>
|
||||
{current.permissions.map((perm) => {
|
||||
const desc = describePermission(perm);
|
||||
return (
|
||||
<li
|
||||
key={perm}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
marginBottom: 6,
|
||||
borderRadius: 8,
|
||||
background: 'var(--accent, #f1f5f9)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>{desc.title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground, #64748b)' }}>{desc.body}</div>
|
||||
<code style={{ fontSize: 10, color: 'var(--muted-foreground, #94a3b8)', display: 'block', marginTop: 4 }}>{perm}</code>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resolveHead(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
onClick={() => resolveHead(true)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
background: '#3b82f6',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
Plugin: {current.pluginId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
// Host-rendered modal for plugin-requested confirm/alert dialogs.
|
||||
// Subscribes to the host-dialog queue and renders the head request, one at
|
||||
// a time. Closing the modal advances the queue.
|
||||
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
export function PluginDialogHost(): React.JSX.Element | null {
|
||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
resolveHead(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
resolveHead(true);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => document.removeEventListener('keydown', onKey, true);
|
||||
}, [current]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : 'Confirm');
|
||||
const cancelLabel = current.cancelLabel ?? 'Cancel';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="plugin-dialog-title"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 100000,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) resolveHead(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background, #fff)',
|
||||
color: 'var(--foreground, #0f172a)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 480,
|
||||
width: '92%',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
||||
{current.title}
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
|
||||
{current.message}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{current.kind === 'confirm' && (
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={!!current.danger}
|
||||
onClick={() => resolveHead(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={current.kind === 'alert' || !current.danger}
|
||||
onClick={() => resolveHead(true)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
background: current.danger ? '#dc2626' : '#3b82f6',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
From plugin: {current.pluginId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPluginAPI, setSlotRegistrationBridge } from '../plugin-api';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { clearAllHooks } from '../plugin-hooks';
|
||||
|
||||
function makePlugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
||||
return {
|
||||
id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
description: '',
|
||||
type: 'ui-extension',
|
||||
entrypoint: 'index.js',
|
||||
permissions: [],
|
||||
enabled: true,
|
||||
status: 'running',
|
||||
settings: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
localStorage.clear();
|
||||
setSlotRegistrationBridge(null);
|
||||
});
|
||||
|
||||
describe('createPluginAPI', () => {
|
||||
it('exposes plugin info', () => {
|
||||
const plugin = makePlugin();
|
||||
const api = createPluginAPI(plugin);
|
||||
expect(api.plugin.id).toBe('test-plugin');
|
||||
expect(api.plugin.version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('returns a frozen copy of settings', () => {
|
||||
const plugin = makePlugin({ settings: { key: 'val' } });
|
||||
const api = createPluginAPI(plugin);
|
||||
expect(api.plugin.settings).toEqual({ key: 'val' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin storage (scoped localStorage)', () => {
|
||||
it('set and get a value', () => {
|
||||
const api = createPluginAPI(makePlugin());
|
||||
api.storage.set('foo', 42);
|
||||
expect(api.storage.get('foo')).toBe(42);
|
||||
});
|
||||
|
||||
it('scopes to plugin id', () => {
|
||||
const api1 = createPluginAPI(makePlugin({ id: 'p1' }));
|
||||
const api2 = createPluginAPI(makePlugin({ id: 'p2' }));
|
||||
api1.storage.set('key', 'a');
|
||||
api2.storage.set('key', 'b');
|
||||
expect(api1.storage.get('key')).toBe('a');
|
||||
expect(api2.storage.get('key')).toBe('b');
|
||||
});
|
||||
|
||||
it('remove deletes a value', () => {
|
||||
const api = createPluginAPI(makePlugin());
|
||||
api.storage.set('x', 10);
|
||||
api.storage.remove('x');
|
||||
expect(api.storage.get('x')).toBeNull();
|
||||
});
|
||||
|
||||
it('keys lists only plugin-scoped keys', () => {
|
||||
const api = createPluginAPI(makePlugin({ id: 'kp' }));
|
||||
api.storage.set('a', 1);
|
||||
api.storage.set('b', 2);
|
||||
localStorage.setItem('unrelated', 'val');
|
||||
expect(api.storage.keys()).toContain('a');
|
||||
expect(api.storage.keys()).toContain('b');
|
||||
expect(api.storage.keys()).not.toContain('unrelated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin logger', () => {
|
||||
it('prefixes log messages with plugin id', () => {
|
||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
const api = createPluginAPI(makePlugin({ id: 'log-test' }));
|
||||
api.log.info('hello');
|
||||
expect(infoSpy).toHaveBeenCalledWith('[plugin:log-test]', 'hello');
|
||||
infoSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hooks permission gating', () => {
|
||||
it('returns no-op disposable without permission', () => {
|
||||
const plugin = makePlugin({ permissions: [] }); // no email:read
|
||||
const api = createPluginAPI(plugin);
|
||||
const d = api.hooks.onEmailOpen(vi.fn());
|
||||
expect(d).toBeDefined();
|
||||
expect(d.dispose).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('registers handler when permission is granted', () => {
|
||||
const plugin = makePlugin({ permissions: ['email:read'] });
|
||||
const api = createPluginAPI(plugin);
|
||||
const fn = vi.fn();
|
||||
const d = api.hooks.onEmailOpen(fn);
|
||||
expect(d).toBeDefined();
|
||||
d.dispose(); // should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui permission requirement', () => {
|
||||
it('throws without ui:toolbar permission', () => {
|
||||
const plugin = makePlugin({ permissions: [] });
|
||||
const api = createPluginAPI(plugin);
|
||||
expect(() => api.ui.registerToolbarAction({
|
||||
id: 'test',
|
||||
label: 'Test',
|
||||
onClick: () => {},
|
||||
})).toThrow('lacks permission');
|
||||
});
|
||||
|
||||
it('does not throw with correct permission (slot bridge not set, returns no-op)', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const plugin = makePlugin({ permissions: ['ui:toolbar'] });
|
||||
const api = createPluginAPI(plugin);
|
||||
const d = api.ui.registerToolbarAction({ id: 'test', label: 'Test', onClick: () => {} });
|
||||
expect(d.dispose).toBeInstanceOf(Function);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slot registration bridge', () => {
|
||||
it('calls bridge when set', () => {
|
||||
const bridge = vi.fn((_name, _reg) => ({ dispose: () => {} }));
|
||||
setSlotRegistrationBridge(bridge);
|
||||
|
||||
const plugin = makePlugin({ permissions: ['ui:email-footer'] });
|
||||
const api = createPluginAPI(plugin);
|
||||
const DummyComponent = () => null;
|
||||
api.ui.registerEmailFooter(DummyComponent);
|
||||
expect(bridge).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toast bridge', () => {
|
||||
it('exposes success/error/info/warning methods', () => {
|
||||
const plugin = makePlugin();
|
||||
const api = createPluginAPI(plugin);
|
||||
expect(api.toast.success).toBeInstanceOf(Function);
|
||||
expect(api.toast.error).toBeInstanceOf(Function);
|
||||
expect(api.toast.info).toBeInstanceOf(Function);
|
||||
expect(api.toast.warning).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('http.post path validation', () => {
|
||||
function makeApi(permissions: string[] = ['http:post']) {
|
||||
return createPluginAPI(makePlugin({ permissions }));
|
||||
}
|
||||
|
||||
it('rejects protocol-relative URLs like //evil.example', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects absolute URLs to other origins', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths not under /api/', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths that use backslash to bypass the check', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws without http:post permission', async () => {
|
||||
const api = makeApi([]);
|
||||
await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
|
||||
});
|
||||
|
||||
it('accepts a valid /api/ path', async () => {
|
||||
const api = makeApi();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
|
||||
});
|
||||
const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { SlotRegistration, InstalledPlugin } from '@/lib/plugin-types';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
// We test the raw store by directly invoking Zustand
|
||||
// Mock the external dependencies the store imports
|
||||
@@ -28,10 +28,6 @@ vi.mock('@/lib/plugin-loader', () => ({
|
||||
setupAutoDisable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-api', () => ({
|
||||
setSlotRegistrationBridge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
removeAllPluginHooks: vi.fn(),
|
||||
}));
|
||||
@@ -42,22 +38,6 @@ import { usePluginStore } from '@/stores/plugin-store';
|
||||
function resetStore() {
|
||||
usePluginStore.setState({
|
||||
plugins: [],
|
||||
slots: {
|
||||
'toolbar-actions': [],
|
||||
'app-top-banner': [],
|
||||
'email-banner': [],
|
||||
'email-footer': [],
|
||||
'composer-toolbar': [],
|
||||
'composer-sidebar': [],
|
||||
'composer-sidebar-right': [],
|
||||
'sidebar-widget': [],
|
||||
'email-detail-sidebar': [],
|
||||
'settings-section': [],
|
||||
'context-menu-email': [],
|
||||
'navigation-rail-bottom': [],
|
||||
'calendar-event-actions': [],
|
||||
'admin-plugin-page': [],
|
||||
},
|
||||
initialized: false,
|
||||
});
|
||||
}
|
||||
@@ -85,31 +65,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('usePluginStore', () => {
|
||||
describe('registerSlot / dispose', () => {
|
||||
it('adds registration to slot and removes on dispose', () => {
|
||||
const { registerSlot } = usePluginStore.getState();
|
||||
const reg: SlotRegistration = {
|
||||
pluginId: 'p1',
|
||||
component: () => null,
|
||||
order: 100,
|
||||
};
|
||||
const disposable = registerSlot('toolbar-actions', reg);
|
||||
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(1);
|
||||
disposable.dispose();
|
||||
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sorts registrations by order', () => {
|
||||
const { registerSlot } = usePluginStore.getState();
|
||||
registerSlot('email-banner', { pluginId: 'p1', component: () => null, order: 200 });
|
||||
registerSlot('email-banner', { pluginId: 'p2', component: () => null, order: 50 });
|
||||
registerSlot('email-banner', { pluginId: 'p3', component: () => null, order: 100 });
|
||||
|
||||
const regs = usePluginStore.getState().slots['email-banner'];
|
||||
expect(regs.map(r => r.pluginId)).toEqual(['p2', 'p3', 'p1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPluginStatus', () => {
|
||||
it('updates status for existing plugin', () => {
|
||||
usePluginStore.setState({ plugins: [mockPlugin()] });
|
||||
|
||||
+11
-4
@@ -92,14 +92,21 @@ export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry.bundlePath],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
// CJS format matches the sandbox runtime's evaluator
|
||||
// (`new Function('module', 'exports', 'require', 'React', ...)`).
|
||||
format: 'cjs',
|
||||
platform: 'neutral',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
sourcemap: 'inline',
|
||||
target: ['es2020'],
|
||||
// React/ReactDOM are exposed on globalThis.__PLUGIN_EXTERNALS__ by the
|
||||
// host, so we mark them external - the bundle won't try to ship them.
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
// The runtime's `require` shim resolves these at evaluation time:
|
||||
// react / react-dom / react-dom/client / react/jsx-runtime → host copies
|
||||
// @plugin-host → the per-plugin `api` object
|
||||
external: [
|
||||
'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime',
|
||||
'@plugin-host',
|
||||
],
|
||||
});
|
||||
const out = result.outputFiles?.[0]?.text;
|
||||
if (!out) throw new Error('esbuild produced no output');
|
||||
|
||||
@@ -1,944 +0,0 @@
|
||||
// PluginAPI factory — builds the sandboxed API facade for each plugin
|
||||
|
||||
import type {
|
||||
Disposable,
|
||||
InstalledPlugin,
|
||||
Permission,
|
||||
ToolbarAction,
|
||||
BannerFactory,
|
||||
SettingsSection,
|
||||
ComposerAction,
|
||||
SidebarWidget,
|
||||
ContextMenuItem,
|
||||
KeyboardShortcut,
|
||||
AdminPageSection,
|
||||
CalendarEventAction,
|
||||
SlotName,
|
||||
PluginI18n,
|
||||
} from './plugin-types';
|
||||
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
|
||||
import {
|
||||
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
|
||||
authHooks, settingsHooks, identityHooks, filterHooks,
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
|
||||
} from './plugin-hooks';
|
||||
import { createPluginI18n } from './plugin-i18n';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
// --- Permission helpers --------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getPluginExternals(): any {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (globalThis as any).__PLUGIN_EXTERNALS__;
|
||||
}
|
||||
|
||||
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
|
||||
if ((IMPLICIT as readonly string[]).includes(perm)) return true;
|
||||
return plugin.permissions.includes(perm);
|
||||
}
|
||||
|
||||
function requirePermission(plugin: InstalledPlugin, perm: Permission): void {
|
||||
if (!hasPermission(plugin, perm)) {
|
||||
throw new Error(`Plugin "${plugin.id}" lacks permission "${perm}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a no-op disposable when permission is missing (silent failure) */
|
||||
function guardedHook<T extends (...args: never[]) => unknown>(
|
||||
plugin: InstalledPlugin,
|
||||
perm: Permission,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
bus: { register: (pluginId: string, handler: any, order?: number) => Disposable },
|
||||
handler: T,
|
||||
order: number = 100,
|
||||
): Disposable {
|
||||
if (!hasPermission(plugin, perm)) {
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
return bus.register(plugin.id, handler, order);
|
||||
}
|
||||
|
||||
// --- Plugin-scoped storage -----------------------------------
|
||||
|
||||
function createPluginStorage(pluginId: string) {
|
||||
const prefix = `plugin:${pluginId}:`;
|
||||
|
||||
return {
|
||||
get: <T>(key: string): T | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const raw = localStorage.getItem(prefix + key);
|
||||
if (raw === null) return null;
|
||||
try { return JSON.parse(raw) as T; } catch { return null; }
|
||||
},
|
||||
set: <T>(key: string, value: T): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(prefix + key, JSON.stringify(value));
|
||||
},
|
||||
remove: (key: string): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(prefix + key);
|
||||
},
|
||||
keys: (): string[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith(prefix)) keys.push(k.slice(prefix.length));
|
||||
}
|
||||
return keys;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Plugin-scoped logger ------------------------------------
|
||||
|
||||
function createPluginLogger(pluginId: string) {
|
||||
const tag = `[plugin:${pluginId}]`;
|
||||
return {
|
||||
debug: (...args: unknown[]) => console.debug(tag, ...args),
|
||||
info: (...args: unknown[]) => console.info(tag, ...args),
|
||||
warn: (...args: unknown[]) => console.warn(tag, ...args),
|
||||
error: (...args: unknown[]) => console.error(tag, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Cross-origin fetch helpers ------------------------------
|
||||
|
||||
/**
|
||||
* Returns true when `url`'s origin is allowed by one of the plugin's
|
||||
* declared `httpOrigins` patterns. Patterns are either a literal origin
|
||||
* (`https://host[:port]`) or a wildcard subdomain form (`https://*.host`).
|
||||
*
|
||||
* Wildcards match exactly one subdomain layer above `host` - e.g.
|
||||
* `https://*.example.com` matches `https://a.example.com` but NOT
|
||||
* `https://example.com` and NOT `https://a.b.example.com`. This mirrors how
|
||||
* the CSP frame-src handles wildcards and avoids accidentally widening
|
||||
* access when the manifest only intended a single tier.
|
||||
*/
|
||||
function originMatchesAllowlist(url: URL, allowlist: string[]): boolean {
|
||||
if (url.protocol !== 'https:') return false;
|
||||
for (const entry of allowlist) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(entry.replace('*.', ''));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed.protocol !== 'https:') continue;
|
||||
const port = url.port || '';
|
||||
const expectedPort = parsed.port || '';
|
||||
if (port !== expectedPort) continue;
|
||||
if (entry.includes('*.')) {
|
||||
const suffix = '.' + parsed.hostname.toLowerCase();
|
||||
if (url.hostname.toLowerCase().endsWith(suffix)) {
|
||||
const prefix = url.hostname.slice(0, url.hostname.length - suffix.length);
|
||||
// Require exactly one non-empty subdomain label.
|
||||
if (prefix.length > 0 && !prefix.includes('.')) return true;
|
||||
}
|
||||
} else {
|
||||
if (url.hostname.toLowerCase() === parsed.hostname.toLowerCase()) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Cross-origin fetch types --------------------------------
|
||||
|
||||
export interface PluginFetchInit {
|
||||
/** HTTP method. Defaults to GET. */
|
||||
method?: string;
|
||||
/** Request headers. Plain object only - no Headers / cookies forwarded. */
|
||||
headers?: Record<string, string>;
|
||||
/** Body. Plain string, ArrayBuffer, Uint8Array, Blob, or FormData. */
|
||||
body?: string | ArrayBuffer | ArrayBufferView | Blob | FormData | null;
|
||||
/** Optional AbortSignal for cancellation. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface PluginFetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
/** Response headers, lower-cased keys. */
|
||||
headers: Record<string, string>;
|
||||
/** Resolves the body as text. */
|
||||
text: () => Promise<string>;
|
||||
/** Resolves the body as parsed JSON, or null on parse error. */
|
||||
json: () => Promise<unknown>;
|
||||
/** Resolves the body as raw bytes. */
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
/** Resolves the body as a Blob. */
|
||||
blob: () => Promise<Blob>;
|
||||
}
|
||||
|
||||
// --- PluginAPI interface -------------------------------------
|
||||
|
||||
export interface PluginAPI {
|
||||
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
||||
/** Localisation API - register translations and call t() to get strings */
|
||||
i18n: PluginI18n;
|
||||
ui: {
|
||||
registerToolbarAction: (action: ToolbarAction) => Disposable;
|
||||
/**
|
||||
* Register a banner that renders at the very top of the authenticated app
|
||||
* shell — above the navigation rail, sidebar and content panes. Used for
|
||||
* persistent global notices (impersonation, maintenance, etc.). The
|
||||
* component receives `{ username, serverUrl }` as props.
|
||||
*
|
||||
* Requires the `ui:app-top-banner` permission.
|
||||
*/
|
||||
registerAppTopBanner: (component: React.ComponentType<Record<string, unknown>>) => Disposable;
|
||||
registerEmailBanner: (factory: BannerFactory) => Disposable;
|
||||
registerEmailFooter: (component: React.ComponentType) => Disposable;
|
||||
registerSettingsSection: (section: SettingsSection) => Disposable;
|
||||
registerComposerAction: (action: ComposerAction) => Disposable;
|
||||
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
|
||||
registerComposerSidebar: (widget: SidebarWidget) => Disposable;
|
||||
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
||||
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
||||
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
|
||||
registerCalendarEventAction: (action: CalendarEventAction) => Disposable;
|
||||
registerAdminPage: (page: AdminPageSection) => Disposable;
|
||||
};
|
||||
hooks: PluginHooksAPI;
|
||||
toast: {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
};
|
||||
http: {
|
||||
post: (path: string, body: Record<string, unknown>) => Promise<{ ok: boolean; status: number; data: unknown }>;
|
||||
/**
|
||||
* Cross-origin fetch against an origin declared in the manifest's
|
||||
* `httpOrigins` allowlist. Requires `http:fetch` permission.
|
||||
*
|
||||
* No webmail credentials are forwarded - the plugin must supply its own
|
||||
* `Authorization` (or other auth) header. Each call is gated on origin
|
||||
* even when the URL came from plugin settings, so a user-pasted URL
|
||||
* outside the allowlist is rejected at the boundary.
|
||||
*/
|
||||
fetch: (url: string, init?: PluginFetchInit) => Promise<PluginFetchResponse>;
|
||||
};
|
||||
storage: ReturnType<typeof createPluginStorage>;
|
||||
log: ReturnType<typeof createPluginLogger>;
|
||||
admin: {
|
||||
getConfig: (key: string) => Promise<unknown>;
|
||||
getAllConfig: () => Promise<Record<string, unknown>>;
|
||||
setConfig: (key: string, value: unknown) => Promise<void>;
|
||||
deleteConfig: (key: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
// Simplified hooks API type (all hooks return Disposable)
|
||||
export interface PluginHooksAPI {
|
||||
// Email
|
||||
onEmailOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailClose: (handler: () => void) => Disposable;
|
||||
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept - receives ComposeOptions, may mutate fields, return false to cancel */
|
||||
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onDraftAutoSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Emitted after emails are moved to the Archive mailbox */
|
||||
onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable;
|
||||
/** Emitted after emails are moved out of the Archive mailbox */
|
||||
onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable;
|
||||
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onMailboxChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onMailboxesRefresh: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onMailboxCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onMailboxRename: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onMailboxDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onMailboxEmpty: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSearch: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSearchResults: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
|
||||
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
/** Transform - receives the OutgoingEmail and returns a (possibly modified) copy */
|
||||
onTransformOutgoingEmail: (handler: (email: import('./plugin-types').OutgoingEmail) => import('./plugin-types').OutgoingEmail | void | Promise<import('./plugin-types').OutgoingEmail | void>) => Disposable;
|
||||
/** Intercept - receives ReplyContext, return false to cancel */
|
||||
onBeforeReply: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onBeforeReplyAll: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onBeforeForward: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
/** Intercept - receives AttachmentInfo, return false to refuse the upload */
|
||||
onBeforeAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onAfterAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
|
||||
onAttachmentDownload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
|
||||
/** Transform - receives AttachmentPreview, may return a modified preview */
|
||||
onAttachmentPreview: (handler: (preview: import('./plugin-types').AttachmentPreview, info: import('./plugin-types').AttachmentInfo) => import('./plugin-types').AttachmentPreview | void | Promise<import('./plugin-types').AttachmentPreview | void>) => Disposable;
|
||||
/** Transform - receives ExternalSearchResult[] and returns an extended array */
|
||||
onProvideSearchResults: (handler: (results: import('./plugin-types').ExternalSearchResult[], ctx: { query: string; filters: import('./plugin-types').SearchFilters }) => import('./plugin-types').ExternalSearchResult[] | void | Promise<import('./plugin-types').ExternalSearchResult[] | void>) => Disposable;
|
||||
/** Observer - debounced snapshot of the composer draft */
|
||||
onDraftChange: (handler: (draft: import('./plugin-types').DraftView) => void) => Disposable;
|
||||
// Calendar
|
||||
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEventRsvp: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEventsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarDateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarViewChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarVisibilityToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Transform - receives ConflictWarning[] and returns an extended array */
|
||||
onCheckEventConflicts: (handler: (warnings: import('./plugin-types').ConflictWarning[], ctx: { event: import('./plugin-types').CalendarEventFormView }) => import('./plugin-types').ConflictWarning[] | void | Promise<import('./plugin-types').ConflictWarning[] | void>) => Disposable;
|
||||
// Calendar Form
|
||||
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Contacts
|
||||
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onContactsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onContactSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Transform - receives RecipientSuggestion[] and returns an extended array */
|
||||
onProvideRecipientSuggestions: (handler: (suggestions: import('./plugin-types').RecipientSuggestion[], ctx: { query: string }) => import('./plugin-types').RecipientSuggestion[] | void | Promise<import('./plugin-types').RecipientSuggestion[] | void>) => Disposable;
|
||||
// Files
|
||||
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileDownload: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileUploadCancel: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept - receives { file: FileResourceView, newName: string }, return false to cancel */
|
||||
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileDuplicate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileFavoriteToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileUndo: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Auth
|
||||
onLogin: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeLogout: (handler: () => void) => Disposable;
|
||||
onAfterLogout: (handler: () => void) => Disposable;
|
||||
onAccountSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAccountAdd: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAccountRemove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTokenRefresh: (handler: () => void) => Disposable;
|
||||
onAuthReady: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Settings
|
||||
onSettingChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSettingsExport: (handler: () => void) => Disposable;
|
||||
onSettingsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSettingsReset: (handler: () => void) => Disposable;
|
||||
onSettingsSync: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTrustedSenderChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Identity
|
||||
onIdentitiesLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onIdentityCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onIdentityUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onIdentityDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onIdentitySelect: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSignatureRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Filters
|
||||
onFiltersLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFilterRuleChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFiltersSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSieveScriptChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Tasks
|
||||
onTasksLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTaskCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTaskUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTaskDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTaskToggleComplete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTaskFilterChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Templates
|
||||
onTemplateCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTemplateUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTemplateDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTemplateApply: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTemplatesImport: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTemplateRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// S/MIME
|
||||
onSmimeKeyImport: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSmimeCertImport: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSmimeKeyStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSmimeDefaultsChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Vacation
|
||||
onVacationLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onVacationUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// UI
|
||||
onViewChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSidebarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSidebarCollapse: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onDeviceTypeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onMobileBack: (handler: () => void) => Disposable;
|
||||
onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept - receives ExternalLinkContext, return false to cancel navigation */
|
||||
onBeforeExternalLink: (handler: (ctx: import('./plugin-types').ExternalLinkContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
/** Observer - debounced text-selection change */
|
||||
onTextSelectionChange: (handler: (ctx: import('./plugin-types').SelectionContext) => void) => Disposable;
|
||||
// Theme
|
||||
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onLocaleChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Toast
|
||||
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Observer fired when an OS-level notification is clicked */
|
||||
onNotificationClick: (handler: (ctx: { tag: string; data?: unknown }) => void) => Disposable;
|
||||
// Drag & Drop
|
||||
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailDrop: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onTagDrop: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Keyboard
|
||||
registerShortcut: (shortcut: KeyboardShortcut) => Disposable;
|
||||
onBeforeShortcut: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterShortcut: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// App Lifecycle
|
||||
onAppReady: (handler: () => void) => Disposable;
|
||||
onVisibilityChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeUnload: (handler: () => void) => Disposable;
|
||||
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onInterval: (handler: () => void, intervalMs: number) => Disposable;
|
||||
/** Observer - browser window focus / blur */
|
||||
onWindowFocus: (handler: () => void) => Disposable;
|
||||
onWindowBlur: (handler: () => void) => Disposable;
|
||||
/** Observer - network connectivity transitions */
|
||||
onOnline: (handler: () => void) => Disposable;
|
||||
onOffline: (handler: () => void) => Disposable;
|
||||
// Account Security
|
||||
onPasswordChange: (handler: () => void) => Disposable;
|
||||
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAppPasswordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEncryptionChange: (handler: () => void) => Disposable;
|
||||
onDisplayNameChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Sidebar Apps
|
||||
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Avatar
|
||||
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Render - transform hook for email list row badges
|
||||
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
||||
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Router
|
||||
/** Observer - fired on every in-app navigation. RouteContext.from holds the previous path. */
|
||||
onNavigate: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
|
||||
onRouteEnter: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
|
||||
onRouteLeave: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
|
||||
}
|
||||
|
||||
// --- Permission mapping for hooks ----------------------------
|
||||
|
||||
const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
// Email
|
||||
onEmailOpen: 'email:read', onEmailClose: 'email:read',
|
||||
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
|
||||
onBeforeCompose: 'email:read', onComposerOpen: 'email:read',
|
||||
onDraftAutoSave: 'email:read',
|
||||
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
|
||||
onSearch: 'email:read', onSearchResults: 'email:read',
|
||||
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
||||
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
||||
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
|
||||
onBeforeReply: 'email:read', onBeforeReplyAll: 'email:read',
|
||||
onBeforeForward: 'email:read', onAttachmentDownload: 'email:read',
|
||||
onAttachmentPreview: 'email:read', onProvideSearchResults: 'email:read',
|
||||
onDraftChange: 'email:read',
|
||||
onBeforeAttachmentUpload: 'email:write', onAfterAttachmentUpload: 'email:write',
|
||||
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
|
||||
onTransformOutgoingEmail: 'email:send',
|
||||
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
||||
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
||||
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
|
||||
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
|
||||
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
|
||||
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
|
||||
onMailboxDelete: 'email:write', onMailboxEmpty: 'email:write',
|
||||
// Calendar
|
||||
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
||||
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
||||
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
||||
onCheckEventConflicts: 'calendar:read',
|
||||
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
|
||||
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
||||
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
||||
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
|
||||
onEventRsvp: 'calendar:write', onEventsImport: 'calendar:write',
|
||||
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
|
||||
// Contacts
|
||||
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
|
||||
onProvideRecipientSuggestions: 'contacts:read',
|
||||
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
|
||||
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
|
||||
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
|
||||
onContactsImport: 'contacts:write', onContactGroupChange: 'contacts:write',
|
||||
onContactGroupMemberChange: 'contacts:write', onContactMove: 'contacts:write',
|
||||
// Files
|
||||
onFileNavigate: 'files:read', onFileDownload: 'files:read', onFileSelectionChange: 'files:read',
|
||||
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
|
||||
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
|
||||
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
|
||||
onBeforeFileRename: 'files:write',
|
||||
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
|
||||
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
|
||||
// Auth
|
||||
onLogin: 'auth:observe', onBeforeLogout: 'auth:observe', onAfterLogout: 'auth:observe',
|
||||
onAccountSwitch: 'auth:observe', onAccountAdd: 'auth:observe', onAccountRemove: 'auth:observe',
|
||||
onTokenRefresh: 'auth:observe', onAuthReady: 'auth:observe',
|
||||
// Settings
|
||||
onSettingChange: 'settings:read', onSettingsExport: 'settings:read',
|
||||
onSettingsImport: 'settings:read', onSettingsReset: 'settings:read',
|
||||
onSettingsSync: 'settings:read', onKeywordChange: 'settings:read',
|
||||
onTrustedSenderChange: 'settings:read',
|
||||
// Identity
|
||||
onIdentitiesLoaded: 'identity:read', onIdentitySelect: 'identity:read',
|
||||
onSignatureRender: 'identity:read',
|
||||
onIdentityCreate: 'identity:write', onIdentityUpdate: 'identity:write',
|
||||
onIdentityDelete: 'identity:write',
|
||||
// Filters
|
||||
onFiltersLoaded: 'filters:read',
|
||||
onFilterRuleChange: 'filters:write', onFiltersSave: 'filters:write',
|
||||
onSieveScriptChange: 'filters:write',
|
||||
// Tasks
|
||||
onTasksLoaded: 'tasks:read', onTaskFilterChange: 'tasks:read',
|
||||
onTaskCreate: 'tasks:write', onTaskUpdate: 'tasks:write',
|
||||
onTaskDelete: 'tasks:write', onTaskToggleComplete: 'tasks:write',
|
||||
// Templates
|
||||
onTemplateApply: 'templates:read', onTemplateRender: 'templates:read',
|
||||
onTemplateCreate: 'templates:write', onTemplateUpdate: 'templates:write',
|
||||
onTemplateDelete: 'templates:write', onTemplatesImport: 'templates:write',
|
||||
// S/MIME
|
||||
onSmimeKeyImport: 'smime:read', onSmimeCertImport: 'smime:read',
|
||||
onSmimeKeyStateChange: 'smime:read', onSmimeDefaultsChange: 'smime:read',
|
||||
// Vacation
|
||||
onVacationLoaded: 'vacation:read', onVacationUpdate: 'vacation:write',
|
||||
// UI
|
||||
onViewChange: 'ui:observe', onSidebarToggle: 'ui:observe',
|
||||
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
|
||||
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
|
||||
onMobileViewSwitch: 'ui:observe',
|
||||
onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe',
|
||||
// Theme
|
||||
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
|
||||
onLocaleChange: 'ui:observe',
|
||||
// Toast
|
||||
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
|
||||
onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe',
|
||||
// Drag & Drop
|
||||
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
|
||||
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
|
||||
// Keyboard
|
||||
registerShortcut: 'ui:keyboard', onBeforeShortcut: 'ui:keyboard',
|
||||
onAfterShortcut: 'ui:keyboard',
|
||||
// App Lifecycle
|
||||
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
|
||||
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
|
||||
onInterval: 'app:lifecycle',
|
||||
onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle',
|
||||
onOnline: 'app:lifecycle', onOffline: 'app:lifecycle',
|
||||
// Account Security
|
||||
onPasswordChange: 'security:read', onTotpChange: 'security:read',
|
||||
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
|
||||
onDisplayNameChange: 'security:read',
|
||||
// Sidebar Apps
|
||||
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
|
||||
onSidebarAppChange: 'ui:observe',
|
||||
// Avatar
|
||||
onAvatarResolve: 'email:read',
|
||||
// Router
|
||||
onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe',
|
||||
};
|
||||
|
||||
// Map hook names → actual HookBus instances
|
||||
const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...args: unknown[]) => unknown, order?: number) => Disposable }> = {
|
||||
// Email
|
||||
...Object.fromEntries(Object.entries(emailHooks)),
|
||||
// Calendar
|
||||
...Object.fromEntries(Object.entries(calendarHooks)),
|
||||
// Calendar Form
|
||||
...Object.fromEntries(Object.entries(calendarFormHooks)),
|
||||
// Contacts
|
||||
...Object.fromEntries(Object.entries(contactHooks)),
|
||||
// Files
|
||||
...Object.fromEntries(Object.entries(fileHooks)),
|
||||
// Auth
|
||||
...Object.fromEntries(Object.entries(authHooks)),
|
||||
// Settings
|
||||
...Object.fromEntries(Object.entries(settingsHooks)),
|
||||
// Identity
|
||||
...Object.fromEntries(Object.entries(identityHooks)),
|
||||
// Filters
|
||||
...Object.fromEntries(Object.entries(filterHooks)),
|
||||
// Tasks
|
||||
...Object.fromEntries(Object.entries(taskHooks)),
|
||||
// Templates
|
||||
...Object.fromEntries(Object.entries(templateHooks)),
|
||||
// S/MIME
|
||||
...Object.fromEntries(Object.entries(smimeHooks)),
|
||||
// Vacation
|
||||
...Object.fromEntries(Object.entries(vacationHooks)),
|
||||
// UI
|
||||
...Object.fromEntries(Object.entries(uiHooks)),
|
||||
// Theme
|
||||
...Object.fromEntries(Object.entries(themeHooks)),
|
||||
// Toast
|
||||
...Object.fromEntries(Object.entries(toastHooks)),
|
||||
// Drag & Drop
|
||||
...Object.fromEntries(Object.entries(dragDropHooks)),
|
||||
// Keyboard
|
||||
...Object.fromEntries(Object.entries(keyboardHooks)),
|
||||
// App Lifecycle
|
||||
...Object.fromEntries(Object.entries(appLifecycleHooks)),
|
||||
// Account Security
|
||||
...Object.fromEntries(Object.entries(accountSecurityHooks)),
|
||||
// Sidebar Apps
|
||||
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
||||
// Avatar
|
||||
...Object.fromEntries(Object.entries(avatarHooks)),
|
||||
// Render
|
||||
...Object.fromEntries(Object.entries(renderHooks)),
|
||||
// Router
|
||||
...Object.fromEntries(Object.entries(routerHooks)),
|
||||
};
|
||||
|
||||
// --- Slot registration bridge --------------------------------
|
||||
// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly
|
||||
|
||||
let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType<Record<string, unknown>>; order: number }) => Disposable) | null = null;
|
||||
|
||||
export function setSlotRegistrationBridge(fn: typeof registerSlotFn): void {
|
||||
registerSlotFn = fn;
|
||||
}
|
||||
|
||||
function registerSlot(
|
||||
pluginId: string,
|
||||
slotName: SlotName,
|
||||
component: React.ComponentType<Record<string, unknown>>,
|
||||
order: number = 100,
|
||||
): Disposable {
|
||||
if (!registerSlotFn) {
|
||||
console.warn(`[plugin:${pluginId}] Slot registration not available yet`);
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
return registerSlotFn(slotName, { pluginId, component, order });
|
||||
}
|
||||
|
||||
// --- Factory -------------------------------------------------
|
||||
|
||||
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
||||
// Build hooks proxy — each hook method checks permission and registers on the right bus
|
||||
const hooks: PluginHooksAPI = {} as PluginHooksAPI;
|
||||
|
||||
for (const [hookName, bus] of Object.entries(HOOK_BUSES)) {
|
||||
const perm = HOOK_PERMISSIONS[hookName];
|
||||
if (!perm) continue;
|
||||
|
||||
if (hookName === 'onInterval') {
|
||||
// Special: onInterval takes (handler, intervalMs)
|
||||
(hooks as unknown as Record<string, unknown>)[hookName] = (handler: () => void, intervalMs: number) => {
|
||||
if (!hasPermission(plugin, perm)) return { dispose: () => {} };
|
||||
const safeMs = Math.max(intervalMs, 60_000); // min 60s
|
||||
const id = setInterval(handler, safeMs);
|
||||
return { dispose: () => clearInterval(id) };
|
||||
};
|
||||
} else if (hookName === 'registerShortcut') {
|
||||
// Special: registerShortcut takes a KeyboardShortcut object
|
||||
(hooks as unknown as Record<string, unknown>)[hookName] = (shortcut: KeyboardShortcut) => {
|
||||
return guardedHook(plugin, perm, bus, shortcut.handler);
|
||||
};
|
||||
} else {
|
||||
(hooks as unknown as Record<string, unknown>)[hookName] = (handler: (...args: unknown[]) => unknown) => {
|
||||
return guardedHook(plugin, perm, bus, handler);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plugin: {
|
||||
id: plugin.id,
|
||||
version: plugin.version,
|
||||
settings: { ...plugin.settings },
|
||||
},
|
||||
|
||||
i18n: createPluginI18n(plugin.id),
|
||||
|
||||
ui: {
|
||||
registerToolbarAction: (action: ToolbarAction) => {
|
||||
requirePermission(plugin, 'ui:toolbar');
|
||||
const Component = () => {
|
||||
const externals = getPluginExternals();
|
||||
const React = externals?.React;
|
||||
if (!React) return null;
|
||||
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
|
||||
return createElement('button', {
|
||||
onClick: action.onClick,
|
||||
className: 'plugin-toolbar-action',
|
||||
title: action.label,
|
||||
}, action.label);
|
||||
};
|
||||
return registerSlot(plugin.id, 'toolbar-actions', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
|
||||
},
|
||||
|
||||
registerEmailBanner: (factory: BannerFactory) => {
|
||||
requirePermission(plugin, 'ui:email-banner');
|
||||
return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType<Record<string, unknown>>, 100);
|
||||
},
|
||||
|
||||
registerAppTopBanner: (component: React.ComponentType<Record<string, unknown>>) => {
|
||||
requirePermission(plugin, 'ui:app-top-banner');
|
||||
return registerSlot(plugin.id, 'app-top-banner', component, 100);
|
||||
},
|
||||
|
||||
registerEmailFooter: (component: React.ComponentType) => {
|
||||
requirePermission(plugin, 'ui:email-footer');
|
||||
return registerSlot(plugin.id, 'email-footer', component as React.ComponentType<Record<string, unknown>>, 100);
|
||||
},
|
||||
|
||||
registerSettingsSection: (section: SettingsSection) => {
|
||||
requirePermission(plugin, 'ui:settings-section');
|
||||
return registerSlot(plugin.id, 'settings-section', section.render as React.ComponentType<Record<string, unknown>>, 100);
|
||||
},
|
||||
|
||||
registerComposerAction: (action: ComposerAction) => {
|
||||
requirePermission(plugin, 'ui:composer-toolbar');
|
||||
const Component = () => {
|
||||
const externals = getPluginExternals();
|
||||
const React = externals?.React;
|
||||
if (!React) return null;
|
||||
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
|
||||
return createElement('button', {
|
||||
onClick: action.onClick,
|
||||
className: 'plugin-composer-action',
|
||||
title: action.label,
|
||||
}, action.label);
|
||||
};
|
||||
return registerSlot(plugin.id, 'composer-toolbar', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
|
||||
},
|
||||
|
||||
registerSidebarWidget: (widget: SidebarWidget) => {
|
||||
requirePermission(plugin, 'ui:sidebar-widget');
|
||||
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||
},
|
||||
|
||||
registerComposerSidebar: (widget: SidebarWidget) => {
|
||||
requirePermission(plugin, 'ui:composer-sidebar');
|
||||
const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar';
|
||||
return registerSlot(plugin.id, slot, widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||
},
|
||||
|
||||
registerDetailSidebar: (widget: SidebarWidget) => {
|
||||
requirePermission(plugin, 'ui:sidebar-widget');
|
||||
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||
},
|
||||
|
||||
registerContextMenuItem: (item: ContextMenuItem) => {
|
||||
requirePermission(plugin, 'ui:context-menu');
|
||||
const Component = () => {
|
||||
const externals = getPluginExternals();
|
||||
const React = externals?.React;
|
||||
if (!React) return null;
|
||||
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
|
||||
return createElement('button', {
|
||||
onClick: () => item.onClick([]),
|
||||
className: 'plugin-context-menu-item',
|
||||
}, item.label);
|
||||
};
|
||||
return registerSlot(plugin.id, 'context-menu-email', Component as React.ComponentType<Record<string, unknown>>, item.order ?? 100);
|
||||
},
|
||||
|
||||
registerNavigationRailItem: (component: React.ComponentType) => {
|
||||
requirePermission(plugin, 'ui:navigation-rail');
|
||||
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
|
||||
},
|
||||
|
||||
registerCalendarEventAction: (action: CalendarEventAction) => {
|
||||
requirePermission(plugin, 'ui:calendar-action');
|
||||
const Component = (props: Record<string, unknown>) => {
|
||||
const externals = getPluginExternals();
|
||||
const React = externals?.React;
|
||||
if (!React) return null;
|
||||
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
|
||||
const iconSpan = createElement('span', {
|
||||
'aria-hidden': 'true',
|
||||
style: { display: 'contents' },
|
||||
dangerouslySetInnerHTML: {
|
||||
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="8" width="14" height="12" rx="2"/></svg>',
|
||||
},
|
||||
});
|
||||
return createElement('button', {
|
||||
onClick: () => action.onClick(
|
||||
props.eventData as import('./plugin-types').CalendarEventFormView,
|
||||
{ setVirtualLocation: props.setVirtualLocation as (url: string) => void },
|
||||
),
|
||||
className: 'inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer',
|
||||
title: action.label,
|
||||
type: 'button',
|
||||
}, iconSpan, action.label);
|
||||
};
|
||||
return registerSlot(plugin.id, 'calendar-event-actions', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
|
||||
},
|
||||
|
||||
registerAdminPage: (page: AdminPageSection) => {
|
||||
requirePermission(plugin, 'ui:admin-page');
|
||||
return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType<Record<string, unknown>>, 100);
|
||||
},
|
||||
},
|
||||
|
||||
hooks,
|
||||
|
||||
toast: {
|
||||
success: (message: string) => appToast.success(message),
|
||||
error: (message: string) => appToast.error(message),
|
||||
info: (message: string) => appToast.info(message),
|
||||
warning: (message: string) => appToast.warning(message),
|
||||
},
|
||||
|
||||
http: {
|
||||
post: async (path: string, body: Record<string, unknown>) => {
|
||||
requirePermission(plugin, 'http:post');
|
||||
if (typeof path !== 'string' || !path.startsWith('/api/')) {
|
||||
throw new Error('path must start with /api/');
|
||||
}
|
||||
const url = new URL(path, globalThis.location.origin);
|
||||
if (url.origin !== globalThis.location.origin) {
|
||||
throw new Error('path must resolve to the same origin');
|
||||
}
|
||||
const { client } = useAuthStore.getState();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (client) {
|
||||
headers['Authorization'] = client.getAuthHeader();
|
||||
headers['X-JMAP-Username'] = client.getUsername();
|
||||
}
|
||||
const res = await fetch(url.pathname + url.search, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
},
|
||||
|
||||
fetch: async (rawUrl: string, init?: PluginFetchInit) => {
|
||||
requirePermission(plugin, 'http:fetch');
|
||||
if (typeof rawUrl !== 'string') {
|
||||
throw new Error('url must be a string');
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error('url must be an absolute https:// URL');
|
||||
}
|
||||
const allowlist = plugin.httpOrigins ?? [];
|
||||
if (allowlist.length === 0) {
|
||||
throw new Error(`Plugin "${plugin.id}" has no httpOrigins declared`);
|
||||
}
|
||||
if (!originMatchesAllowlist(url, allowlist)) {
|
||||
throw new Error(`Origin ${url.origin} not in plugin httpOrigins allowlist`);
|
||||
}
|
||||
// Defence-in-depth: don't let the plugin smuggle a header that the
|
||||
// host's same-origin /api flow uses to authenticate the user.
|
||||
const safeHeaders: Record<string, string> = {};
|
||||
if (init?.headers) {
|
||||
for (const [k, v] of Object.entries(init.headers)) {
|
||||
const lower = k.toLowerCase();
|
||||
if (lower === 'cookie' || lower === 'x-jmap-username') continue;
|
||||
safeHeaders[k] = v;
|
||||
}
|
||||
}
|
||||
const res = await fetch(url.toString(), {
|
||||
method: init?.method ?? 'GET',
|
||||
headers: safeHeaders,
|
||||
// eslint-disable-next-line no-undef
|
||||
body: (init?.body ?? undefined) as BodyInit | undefined,
|
||||
signal: init?.signal,
|
||||
credentials: 'omit',
|
||||
mode: 'cors',
|
||||
redirect: 'follow',
|
||||
});
|
||||
const headersOut: Record<string, string> = {};
|
||||
res.headers.forEach((value, key) => { headersOut[key.toLowerCase()] = value; });
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: headersOut,
|
||||
text: () => res.text(),
|
||||
json: () => res.json().catch(() => null),
|
||||
arrayBuffer: () => res.arrayBuffer(),
|
||||
blob: () => res.blob(),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
storage: createPluginStorage(plugin.id),
|
||||
log: createPluginLogger(plugin.id),
|
||||
|
||||
admin: {
|
||||
getConfig: async (key: string) => {
|
||||
requirePermission(plugin, 'admin:config');
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data[key] ?? null;
|
||||
},
|
||||
getAllConfig: async () => {
|
||||
requirePermission(plugin, 'admin:config');
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
|
||||
if (!res.ok) return {};
|
||||
return res.json();
|
||||
},
|
||||
setConfig: async (key: string, value: unknown) => {
|
||||
requirePermission(plugin, 'admin:config');
|
||||
await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
},
|
||||
deleteConfig: async (key: string) => {
|
||||
requirePermission(plugin, 'admin:config');
|
||||
await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Queue for plugin permission-consent requests.
|
||||
//
|
||||
// On first enable the plugin store posts a ConsentRequest here; the
|
||||
// `PluginConsentDialog` component renders the head and resolves the promise
|
||||
// when the user accepts or rejects. The store persists the granted set in
|
||||
// `plugin.grantedPermissions` so future enables don't re-prompt.
|
||||
|
||||
import type { Permission } from '../plugin-types';
|
||||
|
||||
export interface ConsentRequest {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
permissions: Permission[];
|
||||
resolve: (granted: boolean) => void;
|
||||
}
|
||||
|
||||
const queue: ConsentRequest[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export function requestConsent(pluginId: string, pluginName: string, permissions: Permission[]): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
queue.push({ id: uid(), pluginId, pluginName, permissions, resolve });
|
||||
notify();
|
||||
});
|
||||
}
|
||||
|
||||
export function head(): ConsentRequest | null {
|
||||
return queue[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveHead(granted: boolean): void {
|
||||
const entry = queue.shift();
|
||||
if (!entry) return;
|
||||
try { entry.resolve(granted); } catch { /* ignore */ }
|
||||
notify();
|
||||
}
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
// ─── Friendly labels for permission strings ───────────────────
|
||||
|
||||
const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
|
||||
'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' },
|
||||
'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' },
|
||||
'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' },
|
||||
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
|
||||
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
|
||||
'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' },
|
||||
'contacts:write': { title: 'Modify your contacts', body: 'Create, edit, or delete contact entries.' },
|
||||
'files:read': { title: 'Read your files', body: 'Browse files stored in your WebDAV folders.' },
|
||||
'files:write': { title: 'Modify your files', body: 'Create, edit, rename, move, or delete files.' },
|
||||
'identity:read': { title: 'Read your identities', body: 'Access the From addresses and signatures you send mail from.' },
|
||||
'identity:write': { title: 'Modify your identities', body: 'Create, edit, or delete identities.' },
|
||||
'filters:read': { title: 'Read your filters', body: 'Access your Sieve mail-filter rules.' },
|
||||
'filters:write': { title: 'Modify your filters', body: 'Create, edit, or delete Sieve filter rules.' },
|
||||
'tasks:read': { title: 'Read your tasks', body: 'Access your task list.' },
|
||||
'tasks:write': { title: 'Modify your tasks', body: 'Create, edit, or delete tasks.' },
|
||||
'templates:read': { title: 'Read your templates', body: 'Access stored mail templates.' },
|
||||
'templates:write': { title: 'Modify your templates', body: 'Create, edit, or delete mail templates.' },
|
||||
'smime:read': { title: 'Read your S/MIME state', body: 'Access information about installed S/MIME keys and certificates.' },
|
||||
'vacation:read': { title: 'Read your vacation auto-reply', body: 'See the configured vacation auto-reply state.' },
|
||||
'vacation:write': { title: 'Modify your vacation auto-reply',body: 'Create, change, or remove the vacation auto-reply.' },
|
||||
'settings:read': { title: 'Read your settings', body: 'Access non-secret user preferences.' },
|
||||
'settings:write': { title: 'Modify your settings', body: 'Change non-secret user preferences.' },
|
||||
'security:read': { title: 'Read account security state', body: 'See whether TOTP / encryption are enabled (no secrets exposed).' },
|
||||
'auth:observe': { title: 'Observe login events', body: 'See when you log in, log out, or switch accounts.' },
|
||||
'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' },
|
||||
'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' },
|
||||
'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' },
|
||||
};
|
||||
|
||||
export function describePermission(perm: string): { title: string; body: string } {
|
||||
return PERMISSION_LABELS[perm] ?? { title: perm, body: 'No description available.' };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiFetch } from '../browser-navigation';
|
||||
import { awaitDialog } from './host-dialog';
|
||||
|
||||
const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
// storage is unscoped by the manifest - implicit.
|
||||
@@ -27,11 +28,20 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
'admin.getAllConfig': 'admin:config',
|
||||
'admin.setConfig': 'admin:config',
|
||||
'admin.deleteConfig': 'admin:config',
|
||||
// ui — any plugin can ask the host to render a modal or open a URL.
|
||||
'ui.confirm': null,
|
||||
'ui.alert': null,
|
||||
'ui.openExternalUrl': null,
|
||||
};
|
||||
|
||||
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
|
||||
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
|
||||
return plugin.permissions.includes(perm);
|
||||
if (!plugin.permissions.includes(perm)) return false;
|
||||
// Defense-in-depth: even if the manifest declares a permission, the host
|
||||
// refuses the API call unless an admin has marked the plugin as managed,
|
||||
// or the user has explicitly granted it via the consent dialog.
|
||||
if (plugin.managed) return true;
|
||||
return (plugin.grantedPermissions ?? []).includes(perm);
|
||||
}
|
||||
|
||||
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
|
||||
@@ -228,7 +238,48 @@ export async function dispatchApiCall(
|
||||
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
|
||||
case 'admin.deleteConfig': await adminDelete(plugin.id, args[0] as string); return undefined;
|
||||
|
||||
case 'ui.confirm': {
|
||||
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean };
|
||||
return awaitDialog({
|
||||
pluginId: plugin.id,
|
||||
kind: 'confirm',
|
||||
title: String(opts.title ?? plugin.name ?? 'Confirm'),
|
||||
message: String(opts.message ?? ''),
|
||||
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
|
||||
cancelLabel: typeof opts.cancelLabel === 'string' ? opts.cancelLabel : undefined,
|
||||
danger: !!opts.danger,
|
||||
});
|
||||
}
|
||||
case 'ui.alert': {
|
||||
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string };
|
||||
await awaitDialog({
|
||||
pluginId: plugin.id,
|
||||
kind: 'alert',
|
||||
title: String(opts.title ?? plugin.name ?? 'Notice'),
|
||||
message: String(opts.message ?? ''),
|
||||
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
case 'ui.openExternalUrl': {
|
||||
const url = String(args[0] ?? '');
|
||||
// Only http(s) — the sandbox should not be able to navigate the host
|
||||
// anywhere internal, nor open javascript:/data:/file: schemes.
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); }
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`ui.openExternalUrl: ${parsed.protocol} not allowed`);
|
||||
}
|
||||
const target = typeof args[1] === 'string' ? (args[1] as string) : '_blank';
|
||||
window.open(parsed.toString(), target, 'noopener,noreferrer');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled method "${method}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cleanup hook for unloading plugins ───────────────────────
|
||||
|
||||
export { cancelForPlugin as cancelPluginDialogs } from './host-dialog';
|
||||
|
||||
@@ -14,6 +14,39 @@ import type {
|
||||
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
|
||||
} from './protocol';
|
||||
|
||||
// ─── Callback marshalling ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walks an object graph and replaces any function values with
|
||||
* `{ __pluginCallback: id }` markers, registering each function in `table` so
|
||||
* the iframe can call back later via 'callback-invoke'. Non-plain values
|
||||
* (functions on prototype, DOM nodes, etc.) are dropped.
|
||||
*/
|
||||
function encodeCallbacks(
|
||||
value: unknown,
|
||||
table: Map<string, (...args: unknown[]) => unknown>,
|
||||
depth = 0,
|
||||
): unknown {
|
||||
if (depth > 6) return null; // hard cap to avoid pathological graphs
|
||||
if (value === null || value === undefined) return value;
|
||||
const t = typeof value;
|
||||
if (t === 'function') {
|
||||
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
table.set(id, value as (...args: unknown[]) => unknown);
|
||||
return { __pluginCallback: id };
|
||||
}
|
||||
if (t !== 'object') return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => encodeCallbacks(v, table, depth + 1));
|
||||
}
|
||||
// Plain object — copy own enumerable keys.
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = encodeCallbacks(v, table, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Public option types ─────────────────────────────────────
|
||||
|
||||
export interface BackgroundOptions {
|
||||
@@ -59,6 +92,8 @@ export class SandboxInstance {
|
||||
|
||||
private pendingHookInvokes = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
||||
private pendingShouldShow = new Map<string, (show: boolean) => void>();
|
||||
/** Host-side function references the sandbox can call back via 'callback-invoke'. */
|
||||
private callbackTable = new Map<string, (...args: unknown[]) => unknown>();
|
||||
|
||||
constructor(
|
||||
private plugin: InstalledPlugin,
|
||||
@@ -69,6 +104,12 @@ export class SandboxInstance {
|
||||
this.pluginId = plugin.id;
|
||||
this.mode = initPayload.mode;
|
||||
|
||||
// Slot iframes get `extraProps`; encode any function values now so the
|
||||
// structured-clone send doesn't drop them.
|
||||
if (initPayload.mode === 'slot') {
|
||||
initPayload.extraProps = encodeCallbacks(initPayload.extraProps, this.callbackTable) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
this.readyPromise = new Promise<void>((res) => { this.resolveReady = res; });
|
||||
this.initPromise = new Promise<InitDoneInfo>((res, rej) => {
|
||||
this.resolveInit = res;
|
||||
@@ -148,6 +189,26 @@ export class SandboxInstance {
|
||||
return;
|
||||
}
|
||||
|
||||
case 'callback-invoke': {
|
||||
const { id, callbackId, args } = msg;
|
||||
const fn = this.callbackTable.get(callbackId);
|
||||
if (!fn) {
|
||||
this.send({ type: 'callback-response', id, ok: false, error: `unknown callback ${callbackId}` });
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await Promise.resolve(fn(...(args ?? [])));
|
||||
// Only send back primitives / plain objects; functions inside
|
||||
// results would round-trip but we don't support that yet.
|
||||
this.send({ type: 'callback-response', id, ok: true, result });
|
||||
} catch (err) {
|
||||
this.send({ type: 'callback-response', id, ok: false, error: (err as Error).message ?? String(err) });
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'hook-result': {
|
||||
const entry = this.pendingHookInvokes.get(msg.id);
|
||||
if (!entry) return;
|
||||
@@ -202,7 +263,11 @@ export class SandboxInstance {
|
||||
|
||||
updateProps(props: Record<string, unknown>): void {
|
||||
if (this.destroyed) return;
|
||||
this.send({ type: 'props-update', props });
|
||||
// Stale references would leak if we kept growing the table without
|
||||
// bound; for now we let it grow until destroy(). A future refinement
|
||||
// could diff old vs new props and drop entries no longer referenced.
|
||||
const encoded = encodeCallbacks(props, this.callbackTable) as Record<string, unknown>;
|
||||
this.send({ type: 'props-update', props: encoded });
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
@@ -215,6 +280,7 @@ export class SandboxInstance {
|
||||
}
|
||||
this.pendingHookInvokes.clear();
|
||||
this.pendingShouldShow.clear();
|
||||
this.callbackTable.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +319,14 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
|
||||
pluginId: opts.plugin.id,
|
||||
slot: opts.slot,
|
||||
code: opts.code,
|
||||
manifest: {
|
||||
id: opts.plugin.id,
|
||||
version: opts.plugin.version,
|
||||
permissions: opts.plugin.permissions,
|
||||
settings: { ...opts.plugin.settings },
|
||||
locales: opts.plugin.locales,
|
||||
httpOrigins: opts.plugin.httpOrigins,
|
||||
},
|
||||
extraProps: opts.extraProps,
|
||||
locale: opts.locale,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Process-wide queue for plugin-requested host dialogs (confirm / alert).
|
||||
// The sandboxed plugin posts a `ui.confirm` API request; the host enqueues a
|
||||
// dialog here and resolves the awaited Promise after the user clicks. The
|
||||
// `PluginDialogHost` component subscribes and renders one dialog at a time.
|
||||
|
||||
export type DialogKind = 'confirm' | 'alert';
|
||||
|
||||
export interface DialogRequest {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
kind: DialogKind;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** When true, confirm button uses destructive styling. */
|
||||
danger?: boolean;
|
||||
/** Called when the dialog closes. `ok` is true only for confirm-accept. */
|
||||
resolve: (ok: boolean) => void;
|
||||
}
|
||||
|
||||
const queue: DialogRequest[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export function enqueueDialog(req: Omit<DialogRequest, 'id'>): { id: string } {
|
||||
const entry: DialogRequest = { ...req, id: uid() };
|
||||
queue.push(entry);
|
||||
notify();
|
||||
return { id: entry.id };
|
||||
}
|
||||
|
||||
export function head(): DialogRequest | null {
|
||||
return queue[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveHead(ok: boolean): void {
|
||||
const entry = queue.shift();
|
||||
if (!entry) return;
|
||||
try { entry.resolve(ok); } catch { /* ignore */ }
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Cancel every pending dialog for a plugin (called on unload). */
|
||||
export function cancelForPlugin(pluginId: string): void {
|
||||
let changed = false;
|
||||
for (let i = queue.length - 1; i >= 0; i--) {
|
||||
if (queue[i].pluginId === pluginId) {
|
||||
const entry = queue[i];
|
||||
queue.splice(i, 1);
|
||||
try { entry.resolve(false); } catch { /* ignore */ }
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) notify();
|
||||
}
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper used by host-api to convert an `enqueueDialog` call into a
|
||||
* Promise the plugin-side `await` can land on.
|
||||
*/
|
||||
export function awaitDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
enqueueDialog({ ...req, resolve });
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { verifyBundle } from './bundle-integrity';
|
||||
import { createBackgroundInstance } from './host-bridge';
|
||||
import { register as registerActive, deregister as deregisterActive } from './registry';
|
||||
import { cancelPluginDialogs } from './host-api';
|
||||
|
||||
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
|
||||
|
||||
@@ -125,6 +126,7 @@ export function unloadSandboxedPlugin(pluginId: string): void {
|
||||
}
|
||||
removeAllPluginHooks(pluginId);
|
||||
try { entry.background.destroy(); } catch { /* ignore */ }
|
||||
cancelPluginDialogs(pluginId);
|
||||
pluginErrorTracker.reset(pluginId);
|
||||
storeAccessor?.setPluginStatus(pluginId, 'disabled');
|
||||
console.info(`[plugin-sandbox] "${pluginId}" deactivated`);
|
||||
|
||||
@@ -38,7 +38,25 @@ export interface SlotInit {
|
||||
slot: SlotName;
|
||||
/** Same bundle code as the background instance. */
|
||||
code: string;
|
||||
/** Initial props the host passes through from `PluginSlot` `extraProps`. */
|
||||
/**
|
||||
* Trimmed manifest (mirrors `BackgroundInit.manifest`). Slot iframes get the
|
||||
* same fields so `api.plugin.settings` and `httpOrigins` work identically
|
||||
* to the background context.
|
||||
*/
|
||||
manifest: {
|
||||
id: string;
|
||||
version: string;
|
||||
permissions: string[];
|
||||
settings: Record<string, unknown>;
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
httpOrigins?: string[];
|
||||
};
|
||||
/**
|
||||
* Initial props the host passes through from `PluginSlot` `extraProps`.
|
||||
* Function values are pre-encoded by the host as
|
||||
* `{ __pluginCallback: '<id>' }` markers and rehydrated to stub functions
|
||||
* by the runtime; the stubs round-trip to the host via 'callback-invoke'.
|
||||
*/
|
||||
extraProps: Record<string, unknown>;
|
||||
locale: string;
|
||||
}
|
||||
@@ -67,6 +85,25 @@ export interface ApiRequestMsg {
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
/** Sandbox → host: invoke a function the host passed in via `extraProps`. */
|
||||
export interface CallbackInvokeMsg {
|
||||
type: 'callback-invoke';
|
||||
/** Round-trip id so the host can return a value if the caller awaits. */
|
||||
id: string;
|
||||
/** The callback marker id (matches `__pluginCallback`). */
|
||||
callbackId: string;
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
/** Host → sandbox: response to a callback-invoke. */
|
||||
export interface CallbackResponseMsg {
|
||||
type: 'callback-response';
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface HookResultMsg {
|
||||
type: 'hook-result';
|
||||
id: string;
|
||||
@@ -91,6 +128,7 @@ export type SandboxToHost =
|
||||
| InitDoneMsg
|
||||
| InitErrorMsg
|
||||
| ApiRequestMsg
|
||||
| CallbackInvokeMsg
|
||||
| HookResultMsg
|
||||
| SlotResizeMsg
|
||||
| SlotShouldShowResultMsg;
|
||||
@@ -128,11 +166,25 @@ export interface SlotShouldShowMsg {
|
||||
export type HostToSandbox =
|
||||
| InitMsg
|
||||
| ApiResponseMsg
|
||||
| CallbackResponseMsg
|
||||
| HookInvokeMsg
|
||||
| LocaleChangeMsg
|
||||
| PropsUpdateMsg
|
||||
| SlotShouldShowMsg;
|
||||
|
||||
/** Marker used in extraProps for function values that the host owns. */
|
||||
export interface PluginCallbackMarker {
|
||||
__pluginCallback: string;
|
||||
}
|
||||
|
||||
export function isCallbackMarker(value: unknown): value is PluginCallbackMarker {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as { __pluginCallback?: unknown }).__pluginCallback === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Type guards ─────────────────────────────────────────────
|
||||
|
||||
export function isSandboxMessage(value: unknown): value is SandboxToHost {
|
||||
@@ -154,6 +206,7 @@ export const API_METHODS = [
|
||||
'http.post', 'http.fetch',
|
||||
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
||||
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
||||
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
|
||||
] as const;
|
||||
|
||||
export type ApiMethod = (typeof API_METHODS)[number];
|
||||
|
||||
@@ -46,6 +46,7 @@ let slotName: SlotName | null = null;
|
||||
let bootDone = false;
|
||||
|
||||
const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||
const hookHandlers: Record<string, (...args: unknown[]) => unknown> = {};
|
||||
|
||||
function sendToHost(msg: SandboxToHost): void {
|
||||
@@ -74,7 +75,46 @@ function callApi(method: string, args: unknown[]): Promise<unknown> {
|
||||
});
|
||||
}
|
||||
|
||||
function buildPluginApi(manifest: BackgroundInit['manifest']) {
|
||||
function invokeHostCallback(callbackId: string, args: unknown[]): Promise<unknown> {
|
||||
const id = uid();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingCallbacks.set(id, { resolve, reject });
|
||||
sendToHost({ type: 'callback-invoke', id, callbackId, args });
|
||||
setTimeout(() => {
|
||||
const entry = pendingCallbacks.get(id);
|
||||
if (!entry) return;
|
||||
pendingCallbacks.delete(id);
|
||||
entry.reject(new Error('host callback timed out after 30s'));
|
||||
}, 30_000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks an object graph received from the host and rehydrates
|
||||
* `{ __pluginCallback: id }` markers into stub functions that round-trip via
|
||||
* the 'callback-invoke' RPC. Mirrors `encodeCallbacks` in host-bridge.ts.
|
||||
*/
|
||||
function decodeCallbacks(value: unknown, depth = 0): unknown {
|
||||
if (depth > 6) return null;
|
||||
if (value === null || value === undefined) return value;
|
||||
const t = typeof value;
|
||||
if (t !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map((v) => decodeCallbacks(v, depth + 1));
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj.__pluginCallback === 'string') {
|
||||
const cbId = obj.__pluginCallback;
|
||||
return (...args: unknown[]) => invokeHostCallback(cbId, args);
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = decodeCallbacks(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type PluginManifest = BackgroundInit['manifest'];
|
||||
|
||||
function buildPluginApi(manifest: PluginManifest) {
|
||||
return {
|
||||
plugin: {
|
||||
id: manifest.id,
|
||||
@@ -97,6 +137,17 @@ function buildPluginApi(manifest: BackgroundInit['manifest']) {
|
||||
info: (m: string) => { void callApi('toast.info', [m]); },
|
||||
warning: (m: string) => { void callApi('toast.warning', [m]); },
|
||||
},
|
||||
ui: {
|
||||
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */
|
||||
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
|
||||
callApi('ui.confirm', [opts]) as Promise<boolean>,
|
||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. */
|
||||
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
||||
callApi('ui.alert', [opts]) as Promise<void>,
|
||||
/** Opens an http/https URL in a new tab via host `window.open`. */
|
||||
openExternalUrl: (url: string, target?: string) =>
|
||||
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
||||
},
|
||||
admin: {
|
||||
getConfig: (key: string) => callApi('admin.getConfig', [key]),
|
||||
getAllConfig: () => callApi('admin.getAllConfig', []),
|
||||
@@ -119,8 +170,11 @@ function buildPluginApi(manifest: BackgroundInit['manifest']) {
|
||||
* bundlers should be configured to externalise React; the runtime provides
|
||||
* those modules here. Anything else is refused — the sandbox has no Node-
|
||||
* compatible module resolution and we don't want plugins probing globals.
|
||||
*
|
||||
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
|
||||
* `const api = require('@plugin-host')` in both background and slot modes.
|
||||
*/
|
||||
function makePluginRequire(): (name: string) => unknown {
|
||||
function makePluginRequire(api: ReturnType<typeof buildPluginApi> | null): (name: string) => unknown {
|
||||
const known: Record<string, unknown> = {
|
||||
'react': React,
|
||||
'react-dom': ReactDOM,
|
||||
@@ -128,15 +182,16 @@ function makePluginRequire(): (name: string) => unknown {
|
||||
'react/jsx-runtime': ReactJSXRuntime,
|
||||
'react/jsx-dev-runtime': ReactJSXRuntime,
|
||||
};
|
||||
if (api) known['@plugin-host'] = api;
|
||||
return (name: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
|
||||
throw new Error(`Plugin sandbox: module "${name}" is not available. Externalise it in your bundler or ship it bundled.`);
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateBundle(code: string): PluginExports {
|
||||
function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | null): PluginExports {
|
||||
const mod: { exports: PluginExports } = { exports: {} };
|
||||
const requireShim = makePluginRequire();
|
||||
const requireShim = makePluginRequire(api);
|
||||
let fn: (...args: unknown[]) => void;
|
||||
try {
|
||||
fn = new Function(
|
||||
@@ -161,7 +216,8 @@ function evaluateBundle(code: string): PluginExports {
|
||||
// ─── Init flow ───────────────────────────────────────────────
|
||||
|
||||
async function bootBackground(payload: BackgroundInit): Promise<void> {
|
||||
const exports = evaluateBundle(payload.code);
|
||||
const api = buildPluginApi(payload.manifest);
|
||||
const exports = evaluateBundle(payload.code, api);
|
||||
pluginExports = exports;
|
||||
|
||||
// Register hooks (each value must be a function).
|
||||
@@ -189,14 +245,15 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
|
||||
|
||||
// Side effects.
|
||||
if (typeof exports.activate === 'function') {
|
||||
await Promise.resolve(exports.activate(buildPluginApi(payload.manifest)));
|
||||
await Promise.resolve(exports.activate(api));
|
||||
}
|
||||
|
||||
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo });
|
||||
}
|
||||
|
||||
function bootSlot(payload: SlotInit): void {
|
||||
const exports = evaluateBundle(payload.code);
|
||||
const api = buildPluginApi(payload.manifest);
|
||||
const exports = evaluateBundle(payload.code, api);
|
||||
pluginExports = exports;
|
||||
slotName = payload.slot;
|
||||
|
||||
@@ -208,11 +265,26 @@ function bootSlot(payload: SlotInit): void {
|
||||
const rootEl = document.getElementById('plugin-sandbox-root');
|
||||
if (!rootEl) throw new Error('Sandbox root element missing');
|
||||
|
||||
let currentProps: Record<string, unknown> = payload.extraProps;
|
||||
let currentProps = decodeCallbacks(payload.extraProps) as Record<string, unknown>;
|
||||
const Component = slotDef.component;
|
||||
|
||||
// A trivial pub/sub so host-pushed `props-update` messages re-render the
|
||||
// slot tree without tearing down the iframe.
|
||||
const propsListeners = new Set<(p: Record<string, unknown>) => void>();
|
||||
slotPropsUpdater = (next) => {
|
||||
currentProps = decodeCallbacks(next) as Record<string, unknown>;
|
||||
for (const l of propsListeners) {
|
||||
try { l(currentProps); } catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
const SlotShell = () => {
|
||||
const wrapRef = React.useRef<HTMLDivElement>(null);
|
||||
const [props, setProps] = React.useState(currentProps);
|
||||
React.useEffect(() => {
|
||||
propsListeners.add(setProps);
|
||||
return () => { propsListeners.delete(setProps); };
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!wrapRef.current) return;
|
||||
let lastHeight = -1;
|
||||
@@ -228,7 +300,7 @@ function bootSlot(payload: SlotInit): void {
|
||||
ro.observe(wrapRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return React.createElement('div', { ref: wrapRef }, React.createElement(Component, currentProps));
|
||||
return React.createElement('div', { ref: wrapRef }, React.createElement(Component, props));
|
||||
};
|
||||
|
||||
const reactRoot = ReactDOM.createRoot(rootEl);
|
||||
@@ -236,6 +308,9 @@ function bootSlot(payload: SlotInit): void {
|
||||
sendToHost({ type: 'init-done', hooks: [], slots: [] });
|
||||
}
|
||||
|
||||
// Populated by bootSlot — receives `props-update` messages.
|
||||
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
|
||||
|
||||
async function handleInit(payload: InitPayload): Promise<void> {
|
||||
if (bootDone) return;
|
||||
bootDone = true;
|
||||
@@ -280,6 +355,15 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'callback-response': {
|
||||
const pending = pendingCallbacks.get(msg.id);
|
||||
if (!pending) return;
|
||||
pendingCallbacks.delete(msg.id);
|
||||
if (msg.ok) pending.resolve(msg.result);
|
||||
else pending.reject(new Error(msg.error ?? 'callback error'));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hook-invoke': {
|
||||
const handler = hookHandlers[msg.hookName];
|
||||
if (!handler) {
|
||||
@@ -318,8 +402,7 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
break;
|
||||
|
||||
case 'props-update':
|
||||
// Phase-2: would push updates into the slot shell. Currently the slot
|
||||
// iframe is torn down and recreated when props change at the host.
|
||||
slotPropsUpdater?.(msg.props ?? {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,14 @@ export interface InstalledPlugin {
|
||||
* `api.http.fetch()`. Carried over from the manifest at install time.
|
||||
*/
|
||||
httpOrigins?: string[];
|
||||
/**
|
||||
* Permissions the user has explicitly granted. Populated by the in-app
|
||||
* consent dialog the first time the plugin is enabled. The host API gate
|
||||
* checks this set in addition to `permissions`, so an unapproved permission
|
||||
* cannot be exercised even if it appears in the manifest. Managed plugins
|
||||
* skip the consent prompt (admin pre-approval).
|
||||
*/
|
||||
grantedPermissions?: string[];
|
||||
}
|
||||
|
||||
// ─── UI Slots ────────────────────────────────────────────────
|
||||
|
||||
+32
-62
@@ -1,37 +1,18 @@
|
||||
// Plugin store - manages installed plugins, slot registrations, and lifecycle
|
||||
// Plugin store - manages installed plugins and lifecycle. Slot registrations
|
||||
// are owned by `lib/plugin-sandbox/registry` (per-iframe), not by the store.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type {
|
||||
InstalledPlugin,
|
||||
PluginStatus,
|
||||
SlotName,
|
||||
SlotRegistration,
|
||||
Disposable,
|
||||
} from '@/lib/plugin-types';
|
||||
import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { extractPlugin } from '@/lib/plugin-validator';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader';
|
||||
import { setSlotRegistrationBridge } from '@/lib/plugin-api';
|
||||
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||
import { requestConsent } from '@/lib/plugin-sandbox/consent';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
// ─── Slot State ──────────────────────────────────────────────
|
||||
|
||||
const SLOT_NAMES: SlotName[] = [
|
||||
'toolbar-actions', 'app-top-banner', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar', 'composer-sidebar-right',
|
||||
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
|
||||
'calendar-event-actions', 'admin-plugin-page',
|
||||
];
|
||||
|
||||
function emptySlots(): Record<SlotName, SlotRegistration[]> {
|
||||
const slots = {} as Record<SlotName, SlotRegistration[]>;
|
||||
for (const name of SLOT_NAMES) {
|
||||
slots[name] = [];
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
|
||||
import type { Permission } from '@/lib/plugin-types';
|
||||
|
||||
let pluginInitializationPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -39,7 +20,6 @@ let pluginInitializationPromise: Promise<void> | null = null;
|
||||
|
||||
interface PluginStoreState {
|
||||
plugins: InstalledPlugin[];
|
||||
slots: Record<SlotName, SlotRegistration[]>;
|
||||
initialized: boolean;
|
||||
|
||||
// Management
|
||||
@@ -49,8 +29,7 @@ interface PluginStoreState {
|
||||
disablePlugin: (id: string) => void;
|
||||
updatePluginSettings: (id: string, settings: Record<string, unknown>) => void;
|
||||
|
||||
// Runtime (called by plugin loader / API bridge)
|
||||
registerSlot: (slotName: SlotName, registration: SlotRegistration) => Disposable;
|
||||
// Runtime (called by plugin loader)
|
||||
setPluginStatus: (id: string, status: PluginStatus, error?: string) => void;
|
||||
|
||||
// Init
|
||||
@@ -63,7 +42,6 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
plugins: [],
|
||||
slots: emptySlots(),
|
||||
initialized: false,
|
||||
|
||||
installPlugin: async (file: File) => {
|
||||
@@ -154,15 +132,34 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
const isApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id);
|
||||
if (requireApproval && !isApproved) return;
|
||||
|
||||
// Ensure bridges are wired before loading (may not have run initializePlugins yet)
|
||||
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
|
||||
setSlotRegistrationBridge(get().registerSlot);
|
||||
// Per-user consent gate: prompt for any permission the user has not
|
||||
// explicitly approved yet. Managed plugins (admin-pushed) skip this —
|
||||
// the admin has already approved them at install time.
|
||||
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
|
||||
const granted = new Set<string>(plugin.grantedPermissions ?? []);
|
||||
const missing = (plugin.permissions ?? [])
|
||||
.filter((p): p is Permission => !!p)
|
||||
.filter((p) => !implicit.has(p) && !granted.has(p));
|
||||
if (missing.length > 0 && !plugin.managed) {
|
||||
const accepted = await requestConsent(plugin.id, plugin.name, missing as Permission[]);
|
||||
if (!accepted) return;
|
||||
// Persist the grants so future enables don't re-prompt.
|
||||
const allGranted = [...new Set<string>([...granted, ...missing])];
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === id ? { ...p, grantedPermissions: allGranted } : p
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
set({
|
||||
plugins: plugins.map(p =>
|
||||
// Ensure bridge is wired before loading (may not have run initializePlugins yet)
|
||||
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
|
||||
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p
|
||||
),
|
||||
});
|
||||
}));
|
||||
|
||||
// Load it immediately
|
||||
const updatedPlugin = get().plugins.find(p => p.id === id);
|
||||
@@ -196,29 +193,6 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
});
|
||||
},
|
||||
|
||||
registerSlot: (slotName: SlotName, registration: SlotRegistration): Disposable => {
|
||||
set(state => ({
|
||||
slots: {
|
||||
...state.slots,
|
||||
[slotName]: [
|
||||
...state.slots[slotName],
|
||||
registration,
|
||||
].sort((a, b) => a.order - b.order),
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
set(state => ({
|
||||
slots: {
|
||||
...state.slots,
|
||||
[slotName]: state.slots[slotName].filter(r => r !== registration),
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
setPluginStatus: (id: string, status: PluginStatus, error?: string) => {
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
@@ -246,7 +220,6 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
setPluginStoreAccessor({
|
||||
setPluginStatus: get().setPluginStatus,
|
||||
});
|
||||
setSlotRegistrationBridge(get().registerSlot);
|
||||
setupAutoDisable();
|
||||
|
||||
// Sync server-managed plugins before loading
|
||||
@@ -277,15 +250,12 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
status: p.enabled ? 'enabled' : 'installed',
|
||||
error: undefined,
|
||||
})),
|
||||
// Don't persist slots - they are runtime-only, rebuilt on load
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
state.plugins = markServerManagedPlugins(state.plugins);
|
||||
state.plugins = dedupeInstalledPlugins(state.plugins);
|
||||
// Ensure slots are initialized after rehydration
|
||||
state.slots = emptySlots();
|
||||
state.initialized = false;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user