mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
perf: start plugin slot frames without waiting for the plugin's own frame
This commit is contained in:
@@ -20,6 +20,12 @@ function post(source: Window | null, origin: string, data: unknown) {
|
||||
}
|
||||
|
||||
const instances: Array<{ destroy(): void }> = [];
|
||||
|
||||
/** The frame answers init: its hooks and slots are in place. */
|
||||
function booted<T extends { iframe: HTMLIFrameElement }>(inst: T): T {
|
||||
post(inst.iframe.contentWindow, 'null', { type: 'init-done', hooks: [], slots: [], shortcuts: [] });
|
||||
return inst;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const i of instances.splice(0)) i.destroy();
|
||||
vi.clearAllMocks();
|
||||
@@ -131,7 +137,7 @@ describe('slot sizing', () => {
|
||||
|
||||
describe('shouldShow context', () => {
|
||||
it('leaves host callbacks out instead of failing to post them', async () => {
|
||||
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
|
||||
const inst = booted(createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }));
|
||||
instances.push(inst);
|
||||
const sent = vi.spyOn(inst.iframe.contentWindow!, 'postMessage');
|
||||
|
||||
@@ -144,7 +150,7 @@ describe('shouldShow context', () => {
|
||||
|
||||
describe('host bookkeeping', () => {
|
||||
it('fails a hook call whose arguments cannot be sent, without keeping it pending', async () => {
|
||||
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
|
||||
const inst = booted(createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }));
|
||||
instances.push(inst);
|
||||
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation(() => { throw new DOMException('no clone', 'DataCloneError'); });
|
||||
|
||||
@@ -337,3 +343,40 @@ describe('plugin translations in frames', () => {
|
||||
expect(Object.keys(init.payload.manifest.locales).sort()).toEqual(['en', 'ja']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calls before the background frame has booted', () => {
|
||||
// A plugin offering the slots remembered from its last start is in the
|
||||
// registry while its frame boots; a message posted before init is lost.
|
||||
it('holds shouldShow and hook calls until init-done', async () => {
|
||||
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
|
||||
instances.push(inst);
|
||||
const sent: Array<{ type: string; id?: string }> = [];
|
||||
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation((m: unknown) => { sent.push(m as { type: string }); });
|
||||
|
||||
const show = inst.evaluateShouldShow('email-banner', {});
|
||||
const hook = inst.invokeHook('onAction', [{ actionId: 'x' }]);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(sent.filter((m) => m.type === 'slot-should-show' || m.type === 'hook-invoke')).toEqual([]);
|
||||
|
||||
booted(inst);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const ask = sent.find((m) => m.type === 'slot-should-show')!;
|
||||
const call = sent.find((m) => m.type === 'hook-invoke')!;
|
||||
post(inst.iframe.contentWindow, 'null', { type: 'slot-should-show-result', id: ask.id, show: true });
|
||||
post(inst.iframe.contentWindow, 'null', { type: 'hook-result', id: call.id, ok: true, result: 'done' });
|
||||
await expect(show).resolves.toBe(true);
|
||||
await expect(hook).resolves.toBe('done');
|
||||
});
|
||||
|
||||
it('gives up on them when the frame is destroyed first', async () => {
|
||||
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
|
||||
const show = inst.evaluateShouldShow('email-banner', {});
|
||||
const hook = inst.invokeHook('onAction', []);
|
||||
inst.destroy();
|
||||
|
||||
await expect(show).resolves.toBe(false);
|
||||
await expect(hook).rejects.toThrow(/destroyed/);
|
||||
await expect(inst.initPromise).rejects.toThrow(/destroyed/);
|
||||
expect(inst.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock('../plugin-sandbox/shortcuts', () => ({
|
||||
}));
|
||||
|
||||
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor, setSandboxLocale } from '../plugin-sandbox/loader';
|
||||
import { get as getActive } from '../plugin-sandbox/registry';
|
||||
import { get as getActive, offersForSlot } from '../plugin-sandbox/registry';
|
||||
|
||||
const CODE = 'export default { activate() {} }';
|
||||
const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex');
|
||||
@@ -67,11 +67,13 @@ function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
||||
}
|
||||
|
||||
function fakeBackground() {
|
||||
return {
|
||||
initPromise: Promise.resolve({ hooks: [], slots: [], shortcuts: [] }),
|
||||
const bg = {
|
||||
initPromise: Promise.resolve({ hooks: [], slots: [], shortcuts: [] }) as Promise<unknown>,
|
||||
invokeHook: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
closed: false,
|
||||
destroy: vi.fn(() => { bg.closed = true; }),
|
||||
};
|
||||
return bg;
|
||||
}
|
||||
|
||||
let statuses: Array<{ id: string; status: string; error?: string }>;
|
||||
@@ -260,3 +262,89 @@ describe('loading the same plugin twice', () => {
|
||||
expect(mocks.createBackgroundInstance).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slots remembered from the last start', () => {
|
||||
const OFFERS = [{ name: 'sidebar-section', hasShouldShow: false, order: 100 }];
|
||||
const perms = { permissions: ['ui:sidebar-widget'], grantedPermissions: ['ui:sidebar-widget'] } as Partial<InstalledPlugin>;
|
||||
|
||||
function startingBackground() {
|
||||
let finish!: (info: unknown) => void;
|
||||
let fail!: (err: Error) => void;
|
||||
const bg = fakeBackground();
|
||||
bg.initPromise = new Promise((res, rej) => { finish = res; fail = rej; });
|
||||
mocks.createBackgroundInstance.mockImplementation(() => bg);
|
||||
return { finish, fail };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mocks.getCode.mockResolvedValue(CODE);
|
||||
});
|
||||
|
||||
it('offers them while the background frame is still booting, then the real list', async () => {
|
||||
// A slot frame used to start only after the plugin's own frame had booted.
|
||||
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: HASH, slots: OFFERS }));
|
||||
const { finish } = startingBackground();
|
||||
|
||||
const load = loadSandboxedPlugin(plugin(perms));
|
||||
await vi.waitFor(() => expect(offersForSlot('sidebar-section').map((o) => o.pluginId)).toEqual(['managed-plugin']));
|
||||
expect(lastStatus()?.status).not.toBe('running');
|
||||
|
||||
finish({ hooks: [], slots: [{ name: 'sidebar-section', hasShouldShow: true, order: 5 }], shortcuts: [] });
|
||||
await load;
|
||||
expect(offersForSlot('sidebar-section')).toEqual([{ pluginId: 'managed-plugin', order: 5, hasShouldShow: true }]);
|
||||
expect(lastStatus()).toMatchObject({ status: 'running' });
|
||||
});
|
||||
|
||||
it('remembers the list for the bundle it came from only', async () => {
|
||||
mocks.createBackgroundInstance.mockImplementation(() => ({ ...fakeBackground(), initPromise: Promise.resolve({ hooks: [], slots: OFFERS, shortcuts: [] }) }));
|
||||
await loadSandboxedPlugin(plugin(perms));
|
||||
expect(JSON.parse(localStorage.getItem('plugin-slot-offers:managed-plugin')!)).toEqual({ hash: HASH, slots: OFFERS });
|
||||
unloadSandboxedPlugin('managed-plugin');
|
||||
|
||||
// Another bundle: wait for its own list.
|
||||
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: 'f'.repeat(64), slots: OFFERS }));
|
||||
const { fail } = startingBackground();
|
||||
const load = loadSandboxedPlugin(plugin(perms));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(offersForSlot('sidebar-section')).toEqual([]);
|
||||
fail(new Error('done'));
|
||||
await load;
|
||||
});
|
||||
|
||||
it('does not offer a remembered slot the plugin may not use', async () => {
|
||||
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: HASH, slots: OFFERS }));
|
||||
const { fail } = startingBackground();
|
||||
const load = loadSandboxedPlugin(plugin());
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(offersForSlot('sidebar-section')).toEqual([]);
|
||||
fail(new Error('done'));
|
||||
await load;
|
||||
});
|
||||
|
||||
it('takes them down when the plugin fails to start', async () => {
|
||||
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: HASH, slots: OFFERS }));
|
||||
const { fail } = startingBackground();
|
||||
const load = loadSandboxedPlugin(plugin(perms));
|
||||
await vi.waitFor(() => expect(offersForSlot('sidebar-section')).toHaveLength(1));
|
||||
|
||||
fail(new Error('boom'));
|
||||
await load;
|
||||
expect(offersForSlot('sidebar-section')).toEqual([]);
|
||||
expect(getActive('managed-plugin')).toBeUndefined();
|
||||
expect(lastStatus()).toMatchObject({ status: 'error' });
|
||||
});
|
||||
|
||||
it('is not marked failed when it was turned off while starting', async () => {
|
||||
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: HASH, slots: OFFERS }));
|
||||
const { fail } = startingBackground();
|
||||
const load = loadSandboxedPlugin(plugin(perms));
|
||||
await vi.waitFor(() => expect(getActive('managed-plugin')).toBeDefined());
|
||||
|
||||
unloadSandboxedPlugin('managed-plugin');
|
||||
// What destroy() does to a starting frame.
|
||||
fail(new Error('sandbox destroyed'));
|
||||
await load;
|
||||
expect(lastStatus()).toMatchObject({ status: 'disabled' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('@/lib/plugin-loader', () => ({
|
||||
setPluginStoreAccessor: vi.fn(),
|
||||
setupAutoDisable: vi.fn(),
|
||||
setSandboxLocale: vi.fn(),
|
||||
forgetSlotOffers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
@@ -60,6 +61,7 @@ import { register as registerActive, deregister as deregisterActive, get as getA
|
||||
// Import after mocks
|
||||
import { usePluginStore, PLUGIN_PERSIST_DELAY_MS } from '@/stores/plugin-store';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { forgetSlotOffers } from '@/lib/plugin-loader';
|
||||
|
||||
function resetStore() {
|
||||
usePluginStore.setState({
|
||||
@@ -460,6 +462,7 @@ describe('plugins the admin removed', () => {
|
||||
expect(usePluginStore.getState().plugins.some((p) => p.id === 'gone')).toBe(false);
|
||||
expect(localStorage.getItem('plugin:gone:@acct:notes')).toBeNull();
|
||||
expect(pluginStorage.deletePreview).toHaveBeenCalledWith('gone');
|
||||
expect(forgetSlotOffers).toHaveBeenCalledWith('gone');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/plugin-loader', () => ({
|
||||
setPluginStoreAccessor: vi.fn(),
|
||||
setupAutoDisable: vi.fn(),
|
||||
setSandboxLocale: vi.fn(),
|
||||
forgetSlotOffers: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchServerPluginList } from '@/lib/server-plugin-list';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
||||
// Re-export so the plugin store can keep the sandbox locale in step via this
|
||||
// facade, instead of importing lib/plugin-sandbox/loader directly (which would
|
||||
// also pull the hook buses into consumers' module graphs).
|
||||
export { setSandboxLocale } from './plugin-sandbox/loader';
|
||||
export { setSandboxLocale, forgetSlotOffers } from './plugin-sandbox/loader';
|
||||
export { snapshotRegionalSettings, type RegionalSnapshot } from './plugin-sandbox/host-bridge';
|
||||
|
||||
/**
|
||||
|
||||
@@ -290,6 +290,8 @@ export class SandboxInstance {
|
||||
private rejectInit!: (err: Error) => void;
|
||||
private listener: (ev: MessageEvent) => void;
|
||||
private destroyed = false;
|
||||
/** The frame has answered init-done: its hooks and slots are in place. */
|
||||
private initialized = false;
|
||||
|
||||
private pendingHookInvokes = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
||||
private pendingShouldShow = new Map<string, (show: boolean) => void>();
|
||||
@@ -326,6 +328,8 @@ export class SandboxInstance {
|
||||
this.resolveInit = res;
|
||||
this.rejectInit = rej;
|
||||
});
|
||||
// Nobody waits on a slot frame's init; its rejection is not an error.
|
||||
this.initPromise.catch(() => {});
|
||||
|
||||
this.iframe = document.createElement('iframe');
|
||||
// Privileged tier: same-origin in BOTH dev and prod so the iframe gets real
|
||||
@@ -451,6 +455,7 @@ export class SandboxInstance {
|
||||
return;
|
||||
|
||||
case 'init-done':
|
||||
this.initialized = true;
|
||||
if (this.mode === 'slot') this.iframe.style.visibility = '';
|
||||
this.resolveInit({ hooks: msg.hooks, slots: msg.slots, shortcuts: msg.shortcuts ?? [] });
|
||||
return;
|
||||
@@ -546,6 +551,10 @@ export class SandboxInstance {
|
||||
/** Dispatch a hook handler inside the sandbox; resolves with its return value. */
|
||||
invokeHook(hookName: string, args: unknown[]): Promise<unknown> {
|
||||
if (this.destroyed) return Promise.reject(new Error('sandbox destroyed'));
|
||||
// A plugin offering remembered slots is in the registry before its frame
|
||||
// has booted; a click on one of its actions waits for it (a message
|
||||
// posted before init is lost).
|
||||
if (!this.initialized) return this.initPromise.then(() => this.invokeHook(hookName, args));
|
||||
const id = uid();
|
||||
const p = new Promise<unknown>((resolve, reject) => {
|
||||
// The bus gives up on a slow handler, but the entry used to stay here
|
||||
@@ -571,6 +580,9 @@ export class SandboxInstance {
|
||||
/** Ask the background instance whether a slot should mount for this context. */
|
||||
evaluateShouldShow(slot: SlotName, context: unknown): Promise<boolean> {
|
||||
if (this.destroyed) return Promise.resolve(false);
|
||||
if (!this.initialized) {
|
||||
return this.initPromise.then(() => this.evaluateShouldShow(slot, context), () => false);
|
||||
}
|
||||
const id = uid();
|
||||
const p = new Promise<boolean>((resolve) => {
|
||||
this.pendingShouldShow.set(id, resolve);
|
||||
@@ -649,9 +661,16 @@ export class SandboxInstance {
|
||||
this.send(msg);
|
||||
}
|
||||
|
||||
/** True once destroy() ran. */
|
||||
get closed(): boolean {
|
||||
return this.destroyed;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
// Whoever waits for init (the loader, calls held back below) stops waiting.
|
||||
this.rejectInit(new Error('sandbox destroyed'));
|
||||
liveInstances.delete(this);
|
||||
window.removeEventListener('message', this.listener);
|
||||
this.iframe.remove();
|
||||
|
||||
@@ -18,9 +18,9 @@ import { useEmailStore } from '@/stores/email-store';
|
||||
import { emailToReadView } from '../plugin-projection';
|
||||
import { verifyBundle } from './bundle-integrity';
|
||||
import { downloadManagedBundle } from './bundle-fetch';
|
||||
import { createBackgroundInstance, forEachInstance, snapshotRegionalSettings, type RegionalSnapshot } from './host-bridge';
|
||||
import { createBackgroundInstance, forEachInstance, snapshotRegionalSettings, type InitDoneInfo, type RegionalSnapshot } from './host-bridge';
|
||||
import { resolvePluginTier } from './tier';
|
||||
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries, get as getActive } from './registry';
|
||||
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries, get as getActive, type SlotOffer } from './registry';
|
||||
import { cancelPluginDialogs } from './host-api';
|
||||
import { hasDialogFor, whenNoDialogFor } from './host-dialog';
|
||||
import { registerShortcuts } from './shortcuts';
|
||||
@@ -98,7 +98,7 @@ async function readCachedBundle(plugin: InstalledPlugin): Promise<string | null>
|
||||
}
|
||||
}
|
||||
|
||||
async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
|
||||
async function getBundleCode(plugin: InstalledPlugin): Promise<{ code: string; hash: string }> {
|
||||
// IndexedDB is the fast path: the store's server sync (managed plugins) and
|
||||
// the upload flow (user plugins) both populate it. It is per-browser though,
|
||||
// while a managed plugin's record travels with the server registry to every
|
||||
@@ -111,8 +111,7 @@ async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
|
||||
const cached = await readCachedBundle(plugin);
|
||||
if (cached) {
|
||||
try {
|
||||
await verifyBundle(cached, plugin.bundleHash);
|
||||
return cached;
|
||||
return { code: cached, hash: await verifyBundle(cached, plugin.bundleHash) };
|
||||
} catch (err) {
|
||||
if (!plugin.managed) throw err;
|
||||
// Stale (sync updated the record but not the bytes) or corrupt copy; the
|
||||
@@ -127,14 +126,60 @@ async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
|
||||
const code = await downloadManagedBundle(plugin.id, plugin.bundleHash, {
|
||||
requireSignature: plugin.tier === 'privileged',
|
||||
});
|
||||
await verifyBundle(code, plugin.bundleHash);
|
||||
const hash = await verifyBundle(code, plugin.bundleHash);
|
||||
try {
|
||||
await pluginStorage.saveCode(plugin.id, code);
|
||||
} catch (err) {
|
||||
// Caching is best effort - the plugin runs from the fetched bytes either way.
|
||||
console.warn(`[plugin-sandbox] Could not cache bundle for "${plugin.id}":`, err);
|
||||
}
|
||||
return code;
|
||||
return { code, hash };
|
||||
}
|
||||
|
||||
// ─── Slot offers from the last start ─────────────────────────
|
||||
|
||||
// A plugin's slots are fixed exports of its bundle. They used to be known
|
||||
// only once its background frame had booted and run activate(), so a slot
|
||||
// frame (a sidebar section, say) started ~1 s after the plugin did, behind
|
||||
// it. The offers are kept per bundle hash, and a plugin started with the
|
||||
// same verified bundle offers them right away; its frames then boot next to
|
||||
// the background one. The real list replaces them when init is done. The
|
||||
// record only says where to draw: the same permission filter applies and
|
||||
// the frames run the same verified code. A changed bundle has another hash.
|
||||
const SLOT_CACHE_PREFIX = 'plugin-slot-offers:';
|
||||
|
||||
function readSlotOffers(pluginId: string, hash: string): InitDoneInfo['slots'] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SLOT_CACHE_PREFIX + pluginId);
|
||||
if (!raw) return null;
|
||||
const rec = JSON.parse(raw) as { hash?: unknown; slots?: unknown };
|
||||
if (rec.hash !== hash || !Array.isArray(rec.slots)) return null;
|
||||
return rec.slots.filter((o): o is InitDoneInfo['slots'][number] =>
|
||||
!!o && typeof o === 'object' && typeof (o as { name?: unknown }).name === 'string');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSlotOffers(pluginId: string, hash: string, slots: InitDoneInfo['slots']): void {
|
||||
try {
|
||||
localStorage.setItem(SLOT_CACHE_PREFIX + pluginId, JSON.stringify({ hash, slots }));
|
||||
} catch {
|
||||
// Storage blocked or full: the next start just waits for init again.
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget a plugin's remembered slots (it was removed). */
|
||||
export function forgetSlotOffers(pluginId: string): void {
|
||||
try { localStorage.removeItem(SLOT_CACHE_PREFIX + pluginId); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function permittedOffers(plugin: InstalledPlugin, slots: InitDoneInfo['slots'], warn: boolean): SlotOffer[] {
|
||||
return slots.filter((offer) => {
|
||||
if (mayOfferSlot(plugin, offer.name)) return true;
|
||||
if (warn) console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for slot "${offer.name}"; not rendered`);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Load ─────────────────────────────────────────────────────
|
||||
@@ -166,9 +211,11 @@ const loading = new Map<string, Promise<void>>();
|
||||
*/
|
||||
export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
if (typeof window === 'undefined') return Promise.resolve();
|
||||
if (getActive(plugin.id)) return Promise.resolve();
|
||||
// Before the registry: a plugin offering remembered slots is registered
|
||||
// while it is still starting.
|
||||
const pending = loading.get(plugin.id);
|
||||
if (pending) return pending;
|
||||
if (getActive(plugin.id)) return Promise.resolve();
|
||||
const run = startSandboxedPlugin(plugin).finally(() => loading.delete(plugin.id));
|
||||
loading.set(plugin.id, run);
|
||||
return run;
|
||||
@@ -188,7 +235,7 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
}
|
||||
const tier = resolution.tier;
|
||||
|
||||
const code = await getBundleCode(plugin);
|
||||
const { code, hash } = await getBundleCode(plugin);
|
||||
background = createBackgroundInstance({
|
||||
plugin,
|
||||
code,
|
||||
@@ -196,6 +243,11 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
tier,
|
||||
});
|
||||
|
||||
const remembered = readSlotOffers(plugin.id, hash);
|
||||
if (remembered && remembered.length > 0) {
|
||||
registerActive({ plugin, code, tier, background, slotOffers: permittedOffers(plugin, remembered, false), hookDisposables: [] });
|
||||
}
|
||||
|
||||
// Wait for the background runtime to evaluate the bundle, register hooks,
|
||||
// and enumerate slots. Bounded so a stuck iframe doesn't hang activation.
|
||||
const bg = background;
|
||||
@@ -244,11 +296,8 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const slotOffers = info.slots.filter((offer) => {
|
||||
if (mayOfferSlot(plugin, offer.name)) return true;
|
||||
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for slot "${offer.name}"; not rendered`);
|
||||
return false;
|
||||
});
|
||||
const slotOffers = permittedOffers(plugin, info.slots, true);
|
||||
writeSlotOffers(plugin.id, hash, info.slots);
|
||||
|
||||
registerActive({
|
||||
plugin,
|
||||
@@ -270,6 +319,10 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
}
|
||||
console.info(`[plugin-sandbox] "${plugin.id}" activated (hooks=${info.hooks.length}, slots=${info.slots.length})`);
|
||||
} catch (err) {
|
||||
// Turned off while it was starting: unload has set the status.
|
||||
if (background?.closed && getActive(plugin.id)?.background !== background) return;
|
||||
// Offered from the last start: take the slots back down.
|
||||
if (background && getActive(plugin.id)?.background === background) deregisterActive(plugin.id);
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
|
||||
console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { persist, type PersistStorage, type StorageValue } from 'zustand/middlew
|
||||
import type { InstalledPlugin, PluginStatus, PluginTier } from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { extractPlugin } from '@/lib/plugin-validator';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale, forgetSlotOffers } from '@/lib/plugin-loader';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||
@@ -421,6 +421,7 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
function removePluginData(id: string): void {
|
||||
deactivatePlugin(id);
|
||||
removeAllPluginHooks(id);
|
||||
forgetSlotOffers(id);
|
||||
void pluginStorage.deleteCode(id).catch(() => {});
|
||||
void pluginStorage.deletePreview(id).catch(() => {});
|
||||
// Accounts the user connected for it live on the server (this mail account's).
|
||||
|
||||
Reference in New Issue
Block a user