Feature: localizable sandboxed plugins (manifest locales + api.i18n.t)
The plugin runtime received the active locale (init payload + 'locale-change')
and plugins could declare a `locales` map, but none of it was usable: the
locales never reached the runtime, and buildPluginApi exposed no i18n. So
plugin code calling pluginApi.i18n.t(...) (as the External Link Warning plugin
does) always got undefined and fell back to English.
Thread plugin locales end to end and surface an i18n API:
- ServerPlugin gains `locales`; the upload route persists manifest.locales
(alongside configSchema/settingsSchema), and /api/plugins surfaces it to the
client so it flows registry -> client -> sandbox host-bridge -> runtime.
- runtime sets __PLUGIN_LOCALE__ at init (not only on later 'locale-change')
and buildPluginApi exposes `i18n.locale` + `i18n.t(key, vars)` resolving
against the plugin's declared locales (manifest.locales) with English/key
fallback and {placeholder} interpolation.
Lets any sandboxed plugin localize its strings from its manifest.
This commit is contained in:
@@ -192,6 +192,9 @@ export async function POST(request: NextRequest) {
|
|||||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(manifest.locales && typeof manifest.locales === 'object'
|
||||||
|
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||||
|
: {}),
|
||||||
...(declaredFrameOrigins.length > 0
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export async function GET() {
|
|||||||
// Per-user settings schema, captured from the manifest at upload/load
|
// Per-user settings schema, captured from the manifest at upload/load
|
||||||
// time so the client can render the settings UI without re-parsing.
|
// time so the client can render the settings UI without re-parsing.
|
||||||
settingsSchema: p.settingsSchema,
|
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
|
// Only serve enabled themes
|
||||||
|
|||||||
@@ -7,6 +7,19 @@
|
|||||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||||
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
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
|
||||||
|
? <strong key={i}>{seg}</strong>
|
||||||
|
: <React.Fragment key={i}>{seg}</React.Fragment>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PluginDialogHost(): React.JSX.Element | null {
|
export function PluginDialogHost(): React.JSX.Element | null {
|
||||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||||
|
|
||||||
@@ -50,9 +63,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--background, #fff)',
|
background: 'var(--color-popover, #fff)',
|
||||||
color: 'var(--foreground, #0f172a)',
|
color: 'var(--color-popover-foreground, #0f172a)',
|
||||||
border: '1px solid var(--border, #e2e8f0)',
|
border: '1px solid var(--color-border, #e2e8f0)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
maxWidth: 480,
|
maxWidth: 480,
|
||||||
@@ -63,8 +76,8 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
|||||||
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
||||||
{current.title}
|
{current.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
|
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||||
{current.message}
|
{renderMessage(current.message)}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
{current.kind === 'confirm' && (
|
{current.kind === 'confirm' && (
|
||||||
@@ -78,7 +91,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: '1px solid var(--border, #e2e8f0)',
|
border: '1px solid var(--color-border, #e2e8f0)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
}}
|
}}
|
||||||
@@ -97,14 +110,14 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: '1px solid transparent',
|
border: '1px solid transparent',
|
||||||
background: current.danger ? '#dc2626' : '#3b82f6',
|
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
||||||
color: '#fff',
|
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||||
From plugin: {current.pluginId}
|
From plugin: {current.pluginId}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export interface ServerPlugin {
|
|||||||
forceEnabled?: boolean;
|
forceEnabled?: boolean;
|
||||||
configSchema?: Record<string, PluginConfigField>;
|
configSchema?: Record<string, PluginConfigField>;
|
||||||
settingsSchema?: Record<string, PluginSettingsField>;
|
settingsSchema?: Record<string, PluginSettingsField>;
|
||||||
|
/**
|
||||||
|
* 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<string, Record<string, string>>;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+12
-19
@@ -10,32 +10,25 @@ import {
|
|||||||
activateAllSandboxed,
|
activateAllSandboxed,
|
||||||
deactivateAllSandboxed,
|
deactivateAllSandboxed,
|
||||||
setSandboxStoreAccessor,
|
setSandboxStoreAccessor,
|
||||||
setSandboxLocale,
|
|
||||||
setupSandboxAutoDisable,
|
setupSandboxAutoDisable,
|
||||||
} from './plugin-sandbox/loader';
|
} from './plugin-sandbox/loader';
|
||||||
import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
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__`
|
* Historically re-published React/ReactDOM on `globalThis` for the blob-import
|
||||||
* so blob-imported plugin code could resolve `react`. With the sandbox model
|
* loader, and later also bootstrapped plugin locale sync. Both are obsolete:
|
||||||
* plugins receive React injected as a function argument inside their iframe
|
* the sandbox injects React per-iframe, and locale sync now lives where plugin
|
||||||
* runtime - there is nothing to expose on the host window.
|
* activation is orchestrated (stores/plugin-store -> initializePlugins, via
|
||||||
*
|
* setSandboxLocale). Kept as a no-op for the legacy activateAllPlugins()
|
||||||
* Kept as a no-op for callers that still invoke it during app bootstrap.
|
* wrapper and its test.
|
||||||
*/
|
*/
|
||||||
export function exposePluginExternals(): void {
|
export function exposePluginExternals(): void {
|
||||||
if (typeof window === 'undefined') return;
|
/* no-op */
|
||||||
// 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 */ });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Store accessor (status updates) ──────────────────────────
|
// ─── Store accessor (status updates) ──────────────────────────
|
||||||
|
|||||||
@@ -41,11 +41,16 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor
|
|||||||
|
|
||||||
let currentLocale = 'en';
|
let currentLocale = 'en';
|
||||||
export function setSandboxLocale(locale: string): void {
|
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;
|
currentLocale = locale;
|
||||||
// Push to all active background instances.
|
// Background instances read `currentLocale` at load time; the slot-iframe
|
||||||
// Slot iframes inherit locale at spawn time; they're short-lived.
|
// component reads this global at spawn time (plugin-iframe-slot.tsx). Keep
|
||||||
// (We don't import the registry here to avoid a circular import; the
|
// both in step from one place. Already-running instances are not re-pushed,
|
||||||
// PluginIframeSlot subscribes to locale changes on its own.)
|
// so a locale switch only affects plugins/slots loaded afterwards.
|
||||||
|
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Bundle fetch ─────────────────────────────────────────────
|
// ─── Bundle fetch ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -73,18 +73,26 @@ function uid(): string {
|
|||||||
|
|
||||||
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
|
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
|
||||||
|
|
||||||
function callApi(method: string, args: unknown[]): Promise<unknown> {
|
const DEFAULT_API_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise<unknown> {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
pendingApi.set(id, { resolve, reject });
|
pendingApi.set(id, { resolve, reject });
|
||||||
sendToHost({ type: 'api-request', id, method, args });
|
sendToHost({ type: 'api-request', id, method, args });
|
||||||
// Reject after 30s to prevent unbounded promise leaks if the host hangs.
|
// 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(() => {
|
setTimeout(() => {
|
||||||
const entry = pendingApi.get(id);
|
const entry = pendingApi.get(id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
pendingApi.delete(id);
|
pendingApi.delete(id);
|
||||||
entry.reject(new Error(`API call ${method} timed out after 30s`));
|
entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||||
}, 30_000);
|
}, timeoutMs);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,12 +159,13 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
warning: (m: string) => { void callApi('toast.warning', [m]); },
|
warning: (m: string) => { void callApi('toast.warning', [m]); },
|
||||||
},
|
},
|
||||||
ui: {
|
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 }) =>
|
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
|
||||||
callApi('ui.confirm', [opts]) as Promise<boolean>,
|
callApi('ui.confirm', [opts], 0) as Promise<boolean>,
|
||||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. */
|
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
|
||||||
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
||||||
callApi('ui.alert', [opts]) as Promise<void>,
|
callApi('ui.alert', [opts], 0) as Promise<void>,
|
||||||
/** Opens an http/https URL in a new tab via host `window.open`. */
|
/** Opens an http/https URL in a new tab via host `window.open`. */
|
||||||
openExternalUrl: (url: string, target?: string) =>
|
openExternalUrl: (url: string, target?: string) =>
|
||||||
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
||||||
@@ -173,6 +182,26 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
|
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
|
||||||
error: (...a: unknown[]) => console.error(`[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, string | number>): 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<void> {
|
|||||||
if (bootDone) return;
|
if (bootDone) return;
|
||||||
bootDone = true;
|
bootDone = true;
|
||||||
mode = payload.mode;
|
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 {
|
try {
|
||||||
if (payload.mode === 'background') {
|
if (payload.mode === 'background') {
|
||||||
await bootBackground(payload);
|
await bootBackground(payload);
|
||||||
|
|||||||
+71
-86
@@ -6,7 +6,8 @@ import { persist } from 'zustand/middleware';
|
|||||||
import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types';
|
import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types';
|
||||||
import { pluginStorage } from '@/lib/plugin-storage';
|
import { pluginStorage } from '@/lib/plugin-storage';
|
||||||
import { extractPlugin } from '@/lib/plugin-validator';
|
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 { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||||
import { requestConsent } from '@/lib/plugin-sandbox/consent';
|
import { requestConsent } from '@/lib/plugin-sandbox/consent';
|
||||||
import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity';
|
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';
|
import type { Permission } from '@/lib/plugin-types';
|
||||||
|
|
||||||
let pluginInitializationPromise: Promise<void> | null = null;
|
let pluginInitializationPromise: Promise<void> | null = null;
|
||||||
|
// One-time guard so we attach the locale->sandbox subscription only once.
|
||||||
|
let localeSubscribed = false;
|
||||||
|
|
||||||
// ─── Store Interface ─────────────────────────────────────────
|
// ─── Store Interface ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -258,6 +261,17 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
setPluginStatus: get().setPluginStatus,
|
setPluginStatus: get().setPluginStatus,
|
||||||
});
|
});
|
||||||
setupAutoDisable();
|
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
|
// Sync server-managed plugins before loading
|
||||||
await syncServerPlugins(get, set);
|
await syncServerPlugins(get, set);
|
||||||
@@ -324,6 +338,33 @@ interface ServerPluginInfo {
|
|||||||
apiPostPaths?: string[];
|
apiPostPaths?: string[];
|
||||||
/** Per-user settings schema, captured from the manifest server-side. */
|
/** Per-user settings schema, captured from the manifest server-side. */
|
||||||
settingsSchema?: InstalledPlugin['settingsSchema'];
|
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';
|
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);
|
const local = get().plugins.find(p => p.id === sp.id);
|
||||||
|
|
||||||
if (!local) {
|
if (!local) {
|
||||||
// New server plugin - download and install
|
// New server plugin - download bundle and install.
|
||||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||||
if (!code) continue;
|
if (!code) continue;
|
||||||
|
|
||||||
await pluginStorage.saveCode(sp.id, code);
|
await pluginStorage.saveCode(sp.id, code);
|
||||||
|
|
||||||
const plugin: InstalledPlugin = {
|
const plugin: InstalledPlugin = {
|
||||||
id: sp.id,
|
id: sp.id,
|
||||||
name: sp.name,
|
|
||||||
version: sp.version,
|
|
||||||
author: sp.author,
|
|
||||||
description: sp.description,
|
|
||||||
type: sp.type as InstalledPlugin['type'],
|
type: sp.type as InstalledPlugin['type'],
|
||||||
permissions: sp.permissions,
|
|
||||||
entrypoint: sp.entrypoint,
|
|
||||||
enabled: sp.forceEnabled,
|
enabled: sp.forceEnabled,
|
||||||
status: sp.forceEnabled ? 'enabled' : 'installed',
|
status: sp.forceEnabled ? 'enabled' : 'installed',
|
||||||
managed: true,
|
|
||||||
forceEnabled: sp.forceEnabled,
|
|
||||||
adminApproved: true, // Server-managed plugins are always approved
|
adminApproved: true, // Server-managed plugins are always approved
|
||||||
settings: {},
|
settings: {},
|
||||||
settingsSchema: sp.settingsSchema,
|
...serverMeta(sp),
|
||||||
bundleHash: sp.bundleHash,
|
|
||||||
...(sp.httpOrigins && sp.httpOrigins.length > 0
|
|
||||||
? { httpOrigins: sp.httpOrigins }
|
|
||||||
: {}),
|
|
||||||
...(sp.apiPostPaths && sp.apiPostPaths.length > 0
|
|
||||||
? { apiPostPaths: sp.apiPostPaths }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
set(state =>
|
||||||
set(state => {
|
state.plugins.some(p => p.id === sp.id)
|
||||||
if (state.plugins.some(p => p.id === sp.id)) {
|
? {}
|
||||||
return {};
|
: { plugins: [...state.plugins, plugin] },
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
return { plugins: [...state.plugins, plugin] };
|
|
||||||
});
|
// Existing plugin. Re-download the bundle only when the code actually
|
||||||
} else if (
|
// 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 ||
|
local.version !== sp.version ||
|
||||||
// bundleHash mismatch covers re-uploads of the same version with new
|
// bundleHash mismatch covers re-uploads of the same version with new
|
||||||
// code. Falsy local hash (older installs that never carried one) also
|
// code; a falsy local hash (older installs) also forces a refresh so
|
||||||
// forces a refresh so we capture the hash on the next sync.
|
// we capture the hash on the next sync.
|
||||||
(sp.bundleHash && local.bundleHash !== sp.bundleHash)
|
(!!sp.bundleHash && local.bundleHash !== sp.bundleHash);
|
||||||
) {
|
|
||||||
// Version or content changed - re-download bundle
|
if (needsBundle) {
|
||||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||||
if (!code) continue;
|
if (!code) continue;
|
||||||
|
|
||||||
await pluginStorage.saveCode(sp.id, code);
|
await pluginStorage.saveCode(sp.id, code);
|
||||||
|
}
|
||||||
|
|
||||||
set(state => ({
|
// Force-enable in the same pass when the server flips it on, so the user
|
||||||
plugins: state.plugins.map(p =>
|
// doesn't need a second refresh for it to run.
|
||||||
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;
|
const shouldAutoEnable = sp.forceEnabled && !local.enabled;
|
||||||
set(state => ({
|
const next: InstalledPlugin = {
|
||||||
plugins: state.plugins.map(p =>
|
...local,
|
||||||
p.id === sp.id
|
...serverMeta(sp),
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
managed: true,
|
|
||||||
forceEnabled: sp.forceEnabled,
|
|
||||||
settingsSchema: sp.settingsSchema,
|
|
||||||
...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}),
|
...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}),
|
||||||
}
|
};
|
||||||
: p
|
if (needsBundle || JSON.stringify(next) !== JSON.stringify(local)) {
|
||||||
),
|
|
||||||
}));
|
|
||||||
} 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 => ({
|
set(state => ({
|
||||||
plugins: state.plugins.map(p =>
|
plugins: state.plugins.map(p => (p.id === sp.id ? next : 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
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user