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)),
}));
}
}