// Plugin platform — Host SDK + loader + contribution registry. // // Plugins are plain JS/ESM modules (author in TS → compile to JS). Each ships a // `plugin.json` manifest + a JS entry that exports `activate(host)`. We read the // entry source from disk (Rust) and import it as an ES module from a blob: URL — // no eval, works under CSP `script-src blob:`. The plugin only touches the app // through the `host` object; capabilities are gated by declared permissions. import { p4, PluginInfo } from "./p4"; import { t } from "./i18n"; import { resolveTheme, loadActiveId } from "./themes"; // ---- contribution shapes ---------------------------------------------------- export interface PluginMenuItem { pluginId: string; label: string; run: () => void | Promise } export interface PluginFileItem { pluginId: string; label: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise } export interface PluginView { pluginId: string; id: string; title: string; render: (container: HTMLElement) => void | (() => void) } export interface PluginSubmitHook { pluginId: string; run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise } export type SubmitVerdict = { ok: boolean; message?: string }; interface Registry { menu: PluginMenuItem[]; fileItems: PluginFileItem[]; views: PluginView[]; submitHooks: PluginSubmitHook[] } const registry: Registry = { menu: [], fileItems: [], views: [], submitHooks: [] }; let version = 0; const listeners = new Set<() => void>(); function bump() { version++; listeners.forEach((l) => l()); } export function getRegistry(): Registry { return registry; } export function registryVersion(): number { return version; } export function subscribeRegistry(cb: () => void): () => void { listeners.add(cb); return () => { listeners.delete(cb); }; } function clearRegistry() { registry.menu = []; registry.fileItems = []; registry.views = []; registry.submitHooks = []; } // ---- host context (provided by the app) ------------------------------------- export interface HostContext { flash: (text: string, err?: boolean) => void; notify: (title: string, body: string) => void; info: () => { userName?: string; clientName?: string; clientRoot?: string } | null; selectedFile: () => { depotFile?: string } | null; refresh: () => void; } // ---- the Host SDK a plugin receives ----------------------------------------- export interface HostApi { version: string; plugin: { id: string; name: string; dir: string }; perms: string[]; has: (perm: string) => boolean; p4: Record Promise>; ui: { addMenuItem: (label: string, run: () => void | Promise) => void; addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise) => void; addView: (view: { id: string; title: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void; addSubmitHook: (run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise) => void; }; flash: (text: string, err?: boolean) => void; notify: (title: string, body: string) => void; clipboard: { write: (text: string) => Promise }; storage: { get: (k: string) => string | null; set: (k: string, v: string) => void; remove: (k: string) => void }; info: () => ReturnType; selectedFile: () => { depotFile?: string } | null; refresh: () => void; theme: () => Record; t: typeof t; log: (...a: unknown[]) => void; } const HOST_VERSION = "1.0"; // p4 methods a plugin may call, split by permission. Only whitelisted, existing methods. const P4_READ = ["opened", "dirs", "describe", "changeSizes", "clientSpec"] as const; const P4_WRITE = ["add", "edit", "del", "sync", "reconcile"] as const; function makeHost(info: PluginInfo, ctx: HostContext): HostApi { const perms = Array.isArray(info.permissions) ? info.permissions : []; const has = (p: string) => perms.includes(p); const p4host: Record Promise> = {}; const anyP4 = p4 as unknown as Record Promise>; if (has("p4:read")) for (const m of P4_READ) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a); if (has("p4:write")) for (const m of P4_WRITE) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a); const ns = `exd-plugin:${info.id}:`; const host: HostApi = { version: HOST_VERSION, plugin: { id: info.id, name: info.name, dir: info.dir }, perms, has, p4: p4host, ui: { addMenuItem: (label, run) => { registry.menu.push({ pluginId: info.id, label, run }); bump(); }, addFileContextItem: (label, run) => { registry.fileItems.push({ pluginId: info.id, label, run }); bump(); }, addView: (view) => { registry.views.push({ pluginId: info.id, id: view.id, title: view.title, render: (el) => view.render(el, host) }); bump(); }, addSubmitHook: (run) => { registry.submitHooks.push({ pluginId: info.id, run }); bump(); }, }, flash: ctx.flash, notify: has("notify") ? ctx.notify : () => {}, clipboard: { write: (text) => navigator.clipboard.writeText(text) }, storage: { get: (k) => { try { return localStorage.getItem(ns + k); } catch { return null; } }, set: (k, v) => { try { localStorage.setItem(ns + k, v); } catch {} }, remove: (k) => { try { localStorage.removeItem(ns + k); } catch {} }, }, info: ctx.info, selectedFile: ctx.selectedFile, refresh: ctx.refresh, theme: () => resolveTheme(loadActiveId()).vars, t, log: (...a) => console.log(`[plugin:${info.id}]`, ...a), }; return host; } // import a plugin's source as an ES module from a blob: URL, then activate it async function loadOne(info: PluginInfo, ctx: HostContext): Promise { const code = await p4.pluginReadEntry(info.id); const blob = new Blob([code], { type: "text/javascript" }); const url = URL.createObjectURL(blob); try { const mod = await import(/* @vite-ignore */ url); const activate = mod.activate || mod.default; if (typeof activate !== "function") throw new Error("plugin has no activate(host) export"); await activate(makeHost(info, ctx)); } finally { URL.revokeObjectURL(url); } } /** (Re)load all enabled plugins. Clears the registry first so this is idempotent. */ export async function loadPlugins(ctx: HostContext): Promise<{ loaded: number; errors: string[] }> { clearRegistry(); const errors: string[] = []; let loaded = 0; let list: PluginInfo[] = []; try { list = await p4.pluginList(); } catch (e) { return { loaded: 0, errors: [String(e)] }; } for (const info of list) { if (!info.enabled) continue; try { await loadOne(info, ctx); loaded++; } catch (e) { errors.push(`${info.name || info.id}: ${String(e)}`); } } bump(); return { loaded, errors }; } /** Run all plugin submit hooks; returns the first blocking verdict, or ok. */ export async function runSubmitHooks(files: { depotFile?: string }[]): Promise { for (const h of registry.submitHooks) { try { const v = await h.run(files); if (v && !v.ok) return v; } catch { /* a broken hook must not block submit */ } } return { ok: true }; }