diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 7f03c047..06562701 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -192,6 +192,9 @@ export async function POST(request: NextRequest) { ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } : {}), + ...(manifest.locales && typeof manifest.locales === 'object' + ? { locales: manifest.locales as ServerPlugin['locales'] } + : {}), ...(declaredFrameOrigins.length > 0 ? { frameOrigins: declaredFrameOrigins } : {}), diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 2cde26c6..c6789a16 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -57,6 +57,9 @@ export async function GET() { // Per-user settings schema, captured from the manifest at upload/load // time so the client can render the settings UI without re-parsing. settingsSchema: p.settingsSchema, + // Plugin-declared i18n tables, so the sandbox can localize plugin + // strings via api.i18n.t(). + locales: p.locales, })); // Only serve enabled themes diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx index 4e9acaed..4ffbb8a0 100644 --- a/components/plugins/plugin-dialog-host.tsx +++ b/components/plugins/plugin-dialog-host.tsx @@ -7,6 +7,19 @@ import React, { useEffect, useSyncExternalStore } from 'react'; import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog'; +// Lightweight **bold** support in plugin dialog messages. Everything else is +// rendered literally (newlines come from the parent's white-space: pre-wrap). +// Splitting on the ** delimiter yields alternating plain/bold segments (odd +// indices are bold). Plugins control these strings, so the delimiters balance. +function renderMessage(message?: string): React.ReactNode { + if (!message) return null; + return message.split('**').map((seg, i) => + i % 2 === 1 + ? {seg} + : {seg}, + ); +} + export function PluginDialogHost(): React.JSX.Element | null { const current = useSyncExternalStore(subscribe, head, () => null); @@ -50,9 +63,9 @@ export function PluginDialogHost(): React.JSX.Element | null { >
{current.title} -

- {current.message} +

+ {renderMessage(current.message)}

{current.kind === 'confirm' && ( @@ -78,7 +91,7 @@ export function PluginDialogHost(): React.JSX.Element | null { fontSize: 13, fontWeight: 500, cursor: 'pointer', - border: '1px solid var(--border, #e2e8f0)', + border: '1px solid var(--color-border, #e2e8f0)', background: 'transparent', color: 'inherit', }} @@ -97,14 +110,14 @@ export function PluginDialogHost(): React.JSX.Element | null { fontWeight: 500, cursor: 'pointer', border: '1px solid transparent', - background: current.danger ? '#dc2626' : '#3b82f6', - color: '#fff', + background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)', + color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)', }} > {confirmLabel}
-
+
From plugin: {current.pluginId}
diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index e1754f87..0913adf5 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -53,6 +53,13 @@ export interface ServerPlugin { forceEnabled?: boolean; configSchema?: Record; settingsSchema?: Record; + /** + * Optional per-locale translation tables (locale -> key -> string) declared + * in the plugin manifest. Surfaced to the sandbox so plugin code can call + * `api.i18n.t(key)`; without it a plugin's strings stay in its hardcoded + * default language. + */ + locales?: Record>; installedAt: string; updatedAt: string; /** diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index b388a41f..a4b1aa09 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -10,32 +10,25 @@ import { activateAllSandboxed, deactivateAllSandboxed, setSandboxStoreAccessor, - setSandboxLocale, setupSandboxAutoDisable, } from './plugin-sandbox/loader'; 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'; + /** - * Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__` - * so blob-imported plugin code could resolve `react`. With the sandbox model - * plugins receive React injected as a function argument inside their iframe - * runtime - there is nothing to expose on the host window. - * - * Kept as a no-op for callers that still invoke it during app bootstrap. + * Historically re-published React/ReactDOM on `globalThis` for the blob-import + * loader, and later also bootstrapped plugin locale sync. Both are obsolete: + * the sandbox injects React per-iframe, and locale sync now lives where plugin + * activation is orchestrated (stores/plugin-store -> initializePlugins, via + * setSandboxLocale). Kept as a no-op for the legacy activateAllPlugins() + * wrapper and its test. */ export function exposePluginExternals(): void { - if (typeof window === 'undefined') return; - // Initialise the locale sync once. Importing the store lazily avoids the - // circular module graph we used to fight before the sandbox refactor. - void import('@/stores/locale-store').then(({ useLocaleStore }) => { - setSandboxLocale(useLocaleStore.getState().locale); - useLocaleStore.subscribe((state) => setSandboxLocale(state.locale)); - // Mirror on a global so the slot-iframe component can read it at spawn. - (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale; - useLocaleStore.subscribe((state) => { - (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale; - }); - }).catch(() => { /* locale sync is best-effort */ }); + /* no-op */ } // ─── Store accessor (status updates) ────────────────────────── diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 16894bb8..fbba5721 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -41,11 +41,16 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor let currentLocale = 'en'; export function setSandboxLocale(locale: string): void { + // Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a + // good locale back to '' - the initial 'en' default stands until the real + // locale arrives via the store subscription. + if (!locale) return; currentLocale = locale; - // Push to all active background instances. - // Slot iframes inherit locale at spawn time; they're short-lived. - // (We don't import the registry here to avoid a circular import; the - // PluginIframeSlot subscribes to locale changes on its own.) + // Background instances read `currentLocale` at load time; the slot-iframe + // component reads this global at spawn time (plugin-iframe-slot.tsx). Keep + // both in step from one place. Already-running instances are not re-pushed, + // so a locale switch only affects plugins/slots loaded afterwards. + (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale; } // ─── Bundle fetch ───────────────────────────────────────────── diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index e171875e..dbd4ba51 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -73,18 +73,26 @@ function uid(): string { // ─── Sandboxed API facade (calls flow to host via postMessage) ─ -function callApi(method: string, args: unknown[]): Promise { +const DEFAULT_API_TIMEOUT_MS = 30_000; + +function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise { const id = uid(); return new Promise((resolve, reject) => { pendingApi.set(id, { resolve, reject }); sendToHost({ type: 'api-request', id, method, args }); - // Reject after 30s to prevent unbounded promise leaks if the host hangs. - setTimeout(() => { - const entry = pendingApi.get(id); - if (!entry) return; - pendingApi.delete(id); - entry.reject(new Error(`API call ${method} timed out after 30s`)); - }, 30_000); + // Bounded so a hung host can't leak the promise forever. Interactive UI + // dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait + // for human input, the host always resolves them on confirm/cancel/close, + // and any still-pending call dies with the iframe on teardown - so there's + // nothing to leak, and a thinking user must not trip a 30s timeout. + if (timeoutMs > 0 && Number.isFinite(timeoutMs)) { + setTimeout(() => { + const entry = pendingApi.get(id); + if (!entry) return; + pendingApi.delete(id); + entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`)); + }, timeoutMs); + } }); } @@ -151,12 +159,13 @@ function buildPluginApi(manifest: PluginManifest) { warning: (m: string) => { void callApi('toast.warning', [m]); }, }, ui: { - /** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */ + /** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. + * No timeout - it waits for the user's choice. */ confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) => - callApi('ui.confirm', [opts]) as Promise, - /** Opens a host-rendered alert (one button). Resolves once dismissed. */ + callApi('ui.confirm', [opts], 0) as Promise, + /** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */ alert: (opts: { title?: string; message?: string; confirmLabel?: string }) => - callApi('ui.alert', [opts]) as Promise, + callApi('ui.alert', [opts], 0) as Promise, /** 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, @@ -173,6 +182,26 @@ function buildPluginApi(manifest: PluginManifest) { warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a), error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a), }, + // Localization for plugins. The host pushes the active locale (init + + // 'locale-change'); `t` resolves a key against the plugin's declared + // `locales` map (manifest.locales), falling back to English then the key + // itself, with optional {placeholder} interpolation. + i18n: { + get locale(): string { + return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; + }, + t(key: string, vars?: Record): string { + const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; + const tables = manifest.locales || {}; + let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key; + if (vars) { + for (const [k, v] of Object.entries(vars)) { + out = out.split('{' + k + '}').join(String(v)); + } + } + return out; + }, + }, }; } @@ -344,6 +373,9 @@ async function handleInit(payload: InitPayload): Promise { if (bootDone) return; bootDone = true; mode = payload.mode; + // Make the active locale available to plugin code (api.i18n) right away - + // not only after the first 'locale-change' push. + (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale; try { if (payload.mode === 'background') { await bootBackground(payload); diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 017b7007..ad8d0604 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -6,7 +6,8 @@ import { persist } from 'zustand/middleware'; 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 { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader'; +import { useLocaleStore } from '@/stores/locale-store'; import { removeAllPluginHooks } from '@/lib/plugin-hooks'; import { requestConsent } from '@/lib/plugin-sandbox/consent'; import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity'; @@ -17,6 +18,8 @@ import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import type { Permission } from '@/lib/plugin-types'; let pluginInitializationPromise: Promise | null = null; +// One-time guard so we attach the locale->sandbox subscription only once. +let localeSubscribed = false; // ─── Store Interface ───────────────────────────────────────── @@ -258,6 +261,17 @@ export const usePluginStore = create()( setPluginStatus: get().setPluginStatus, }); setupAutoDisable(); + // Keep the sandbox locale in step with the app locale. Set it + // synchronously *before* activation so background instances get the + // right locale in their init payload (the bug: the only wiring lived + // in the dead activateAllPlugins() path, so the sandbox locale stayed + // 'en' forever and plugin i18n never localized). Subscribe once for + // later language switches; those affect plugins/slots loaded after. + setSandboxLocale(useLocaleStore.getState().locale); + if (!localeSubscribed) { + localeSubscribed = true; + useLocaleStore.subscribe((s) => setSandboxLocale(s.locale)); + } // Sync server-managed plugins before loading await syncServerPlugins(get, set); @@ -324,6 +338,33 @@ interface ServerPluginInfo { apiPostPaths?: string[]; /** Per-user settings schema, captured from the manifest server-side. */ settingsSchema?: InstalledPlugin['settingsSchema']; + /** Plugin-declared i18n tables (locale -> key -> string), from the manifest. */ + locales?: InstalledPlugin['locales']; +} + +/** + * Server-owned metadata, passed through verbatim on every sync. Centralised in + * ONE place so a newly added passthrough field can't be silently dropped at one + * of several copy sites - which is exactly what previously lost `settingsSchema` + * (hence the old "schema drift" special-case) and then `locales`. Excludes + * fields the client owns (id, type, enabled/status, settings, adminApproved). + */ +function serverMeta(sp: ServerPluginInfo) { + return { + name: sp.name, + version: sp.version, + author: sp.author, + description: sp.description, + permissions: sp.permissions, + entrypoint: sp.entrypoint, + managed: true as const, + forceEnabled: sp.forceEnabled, + bundleHash: sp.bundleHash, + httpOrigins: sp.httpOrigins, + apiPostPaths: sp.apiPostPaths, + settingsSchema: sp.settingsSchema, + locales: sp.locales, + }; } const SERVER_MANAGED_KEY = 'server-managed-plugin-ids'; @@ -402,113 +443,57 @@ async function syncServerPlugins( const local = get().plugins.find(p => p.id === sp.id); if (!local) { - // New server plugin - download and install + // New server plugin - download bundle and install. const code = await downloadPluginBundle(sp.id, sp.bundleHash); if (!code) continue; - await pluginStorage.saveCode(sp.id, code); const plugin: InstalledPlugin = { id: sp.id, - name: sp.name, - version: sp.version, - author: sp.author, - description: sp.description, type: sp.type as InstalledPlugin['type'], - permissions: sp.permissions, - entrypoint: sp.entrypoint, enabled: sp.forceEnabled, status: sp.forceEnabled ? 'enabled' : 'installed', - managed: true, - forceEnabled: sp.forceEnabled, adminApproved: true, // Server-managed plugins are always approved settings: {}, - settingsSchema: sp.settingsSchema, - bundleHash: sp.bundleHash, - ...(sp.httpOrigins && sp.httpOrigins.length > 0 - ? { httpOrigins: sp.httpOrigins } - : {}), - ...(sp.apiPostPaths && sp.apiPostPaths.length > 0 - ? { apiPostPaths: sp.apiPostPaths } - : {}), + ...serverMeta(sp), }; + set(state => + state.plugins.some(p => p.id === sp.id) + ? {} + : { plugins: [...state.plugins, plugin] }, + ); + continue; + } - set(state => { - if (state.plugins.some(p => p.id === sp.id)) { - return {}; - } - return { plugins: [...state.plugins, plugin] }; - }); - } else if ( + // Existing plugin. Re-download the bundle only when the code actually + // changed, but ALWAYS re-derive server-owned metadata from one place + // (serverMeta) so no passthrough field is silently dropped on a + // metadata-only change. Only write when something differs, to avoid a + // needless persist/re-render on every sync. + const needsBundle = local.version !== sp.version || // bundleHash mismatch covers re-uploads of the same version with new - // code. Falsy local hash (older installs that never carried one) also - // forces a refresh so we capture the hash on the next sync. - (sp.bundleHash && local.bundleHash !== sp.bundleHash) - ) { - // Version or content changed - re-download bundle + // code; a falsy local hash (older installs) also forces a refresh so + // we capture the hash on the next sync. + (!!sp.bundleHash && local.bundleHash !== sp.bundleHash); + + if (needsBundle) { const code = await downloadPluginBundle(sp.id, sp.bundleHash); if (!code) continue; - await pluginStorage.saveCode(sp.id, code); + } + // Force-enable in the same pass when the server flips it on, so the user + // doesn't need a second refresh for it to run. + const shouldAutoEnable = sp.forceEnabled && !local.enabled; + const next: InstalledPlugin = { + ...local, + ...serverMeta(sp), + ...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}), + }; + if (needsBundle || JSON.stringify(next) !== JSON.stringify(local)) { set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id - ? { - ...p, - name: sp.name, - version: sp.version, - author: sp.author, - description: sp.description, - permissions: sp.permissions, - entrypoint: sp.entrypoint, - managed: true, - forceEnabled: sp.forceEnabled, - bundleHash: sp.bundleHash, - httpOrigins: sp.httpOrigins, - apiPostPaths: sp.apiPostPaths, - settingsSchema: sp.settingsSchema, - } - : p - ), - })); - } else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) { - // When forceEnabled flips on, enable the plugin in the same pass so - // the user doesn't need a second refresh for it to run. - const shouldAutoEnable = sp.forceEnabled && !local.enabled; - set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id - ? { - ...p, - managed: true, - forceEnabled: sp.forceEnabled, - settingsSchema: sp.settingsSchema, - ...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}), - } - : p - ), - })); - } else if ( - JSON.stringify(local.settingsSchema ?? null) !== JSON.stringify(sp.settingsSchema ?? null) - ) { - // Schema drift: the bundle is current but the persisted plugin record - // pre-dates the server passing settingsSchema through, so the per-user - // settings UI was rendering empty. Patch the schema in place. - set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id ? { ...p, settingsSchema: sp.settingsSchema } : p - ), - })); - } else if (sp.forceEnabled && !local.enabled) { - // Force-enable if the server says so but client has it disabled - set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id - ? { ...p, enabled: true, status: 'enabled' as const, managed: true, forceEnabled: true } - : p - ), + plugins: state.plugins.map(p => (p.id === sp.id ? next : p)), })); } }