feat: mirror regional presentation settings to plugin sandboxes

Plugins running in iframe sandboxes previously lacked access to the user's regional formatting settings (dateLocale, timeFormat 12h/24h, timeZone, and firstDayOfWeek) unless they requested privileged settings:read permissions.

This mirrors the host's resolved presentation preferences into the plugin sandbox:
- Adds RegionalSnapshot (locale, dateLocale, hour12, timeZone, firstDayOfWeek) to BackgroundInit, SlotInit, and LocaleChangeMsg.
- Implements snapshotRegionalSettings() in host-bridge, reusing host resolveDateLocale() and getEffectiveTimeZone().
- Subscribes stores/plugin-store.ts to useSettingsStore to broadcast updates to running sandboxes when regional preferences change.
- Exposes api.i18n.formatTime(date, options) and api.i18n.formatDate(date, options) along with regional getters on api.i18n.
This commit is contained in:
Tobias Genannt
2026-10-24 11:01:13 +02:00
parent d1f4ccaccf
commit 2b5a9dbfc5
10 changed files with 175 additions and 20 deletions
@@ -29,6 +29,13 @@ vi.mock('../plugin-sandbox/host-bridge', () => ({
destroy: vi.fn(),
};
},
snapshotRegionalSettings: vi.fn((locale: string) => ({
locale,
dateLocale: locale,
hour12: false,
timeZone: 'UTC',
firstDayOfWeek: 1,
})),
SandboxInstance: class {},
}));
vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn() }));
+9 -2
View File
@@ -23,7 +23,14 @@ vi.mock('../plugin-sandbox/bundle-fetch', () => ({
}));
vi.mock('../plugin-sandbox/host-bridge', () => ({
createBackgroundInstance: (...args: unknown[]) => mocks.createBackgroundInstance(...args),
forEachInstance: (_id: string | null, fn: (i: { setLocale: (l: string) => void }) => void) => fn({ setLocale: mocks.setLocale }),
forEachInstance: (_id: string | null, fn: (i: { setLocale: (l: string, r?: unknown) => void }) => void) => fn({ setLocale: mocks.setLocale }),
snapshotRegionalSettings: vi.fn((locale: string) => ({
locale,
dateLocale: locale,
hour12: false,
timeZone: 'UTC',
firstDayOfWeek: 1,
})),
SandboxInstance: class {},
}));
vi.mock('../plugin-sandbox/host-api', () => ({
@@ -216,7 +223,7 @@ describe('setSandboxLocale', () => {
setSandboxLocale('de');
expect(mocks.setLocale).toHaveBeenCalledWith('de');
expect(mocks.setLocale).toHaveBeenCalledWith('de', expect.anything());
});
});
+7
View File
@@ -43,6 +43,13 @@ vi.mock('@/lib/browser-navigation', () => ({
const bridge = vi.hoisted(() => ({ setSettings: vi.fn() }));
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
forEachInstance: (_id: string, fn: (i: { setSettings: typeof bridge.setSettings }) => void) => fn({ setSettings: bridge.setSettings }),
snapshotRegionalSettings: vi.fn((locale: string) => ({
locale,
dateLocale: locale,
hour12: false,
timeZone: 'UTC',
firstDayOfWeek: 1,
})),
}));
vi.mock('@/lib/plugin-sandbox/bundle-fetch', () => ({
downloadManagedBundle: (...args: unknown[]) => mocks.downloadManagedBundle(...args),
+1
View File
@@ -18,6 +18,7 @@ import { all as allActive, get as getActive } from './plugin-sandbox/registry';
// 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 { snapshotRegionalSettings, type RegionalSnapshot } from './plugin-sandbox/host-bridge';
/**
* Historically re-published React/ReactDOM on `globalThis` for the blob-import
+44 -3
View File
@@ -9,11 +9,16 @@
import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
import { dispatchApiCall } from './host-api';
import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH } from './protocol';
import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH, type RegionalSnapshot } from './protocol';
export type { RegionalSnapshot };
import { withBasePath } from '../browser-navigation';
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
import { effectiveSettings } from '../plugin-settings';
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import { resolveDateLocale } from '../utils';
import { getEffectiveTimeZone } from '../timezone';
import { getEffectiveLocale } from '@/i18n/detect-locale';
import { useSettingsStore, type DateLocale } from '@/stores/settings-store';
import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg,
FloatingDropdownOpenMsg, FloatingDropdownUpdateMsg, FloatingDropdownCloseMsg,
@@ -140,12 +145,44 @@ function encodeCallbacks(
return out;
}
// ─── Regional settings ───────────────────────────────────────
/**
* Resolves the user's current regional formatting preferences (date locale,
* time format, effective time zone, first day of week) so sandboxed plugin
* runtimes can format dates and times consistently without needing
* privileged `settings:read` permissions or duplicating formatting logic.
*/
export function snapshotRegionalSettings(localeOverride?: string): RegionalSnapshot {
const appLocale = localeOverride ?? (typeof getEffectiveLocale === 'function' ? getEffectiveLocale() : 'en');
const settings = typeof useSettingsStore?.getState === 'function' ? useSettingsStore.getState() : ({} as Record<string, unknown>);
const dateLocale = resolveDateLocale(settings?.dateLocale as DateLocale | undefined, appLocale) ?? appLocale;
const hour12 = settings?.timeFormat === '12h';
const timeZone = typeof getEffectiveTimeZone === 'function'
? getEffectiveTimeZone()
: (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' : 'UTC');
const rawFirstDay = settings?.firstDayOfWeek;
const firstDayOfWeek: 0 | 1 | 6 = (rawFirstDay === 0 || rawFirstDay === 6 || rawFirstDay === 1)
? rawFirstDay
: 1;
return {
locale: appLocale,
dateLocale,
hour12,
timeZone,
firstDayOfWeek,
};
}
// ─── Public option types ─────────────────────────────────────
export interface BackgroundOptions {
plugin: InstalledPlugin;
code: string;
locale: string;
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
regional?: RegionalSnapshot;
/** Resolved execution tier (from `resolvePluginTier`). */
tier: PluginTier;
/** Where the hidden iframe should attach. Defaults to document.body. */
@@ -157,6 +194,8 @@ export interface SlotOptions {
slot: SlotName;
code: string;
locale: string;
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
regional?: RegionalSnapshot;
/** Resolved execution tier (from `resolvePluginTier`). */
tier: PluginTier;
extraProps: Record<string, unknown>;
@@ -540,9 +579,9 @@ export class SandboxInstance {
this.send(msg);
}
setLocale(locale: string): void {
setLocale(locale: string, regional?: RegionalSnapshot): void {
if (this.destroyed) return;
this.send({ type: 'locale-change', locale });
this.send({ type: 'locale-change', locale, regional: regional ?? snapshotRegionalSettings(locale) });
}
/** Push a new resolved theme so the slot iframe re-injects its theme CSS. */
@@ -642,6 +681,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan
},
code: opts.code,
locale: opts.locale,
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
};
return new SandboxInstance(
opts.plugin,
@@ -669,6 +709,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
extraProps: opts.extraProps,
...(opts.fill ? { fill: true } : {}),
locale: opts.locale,
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
theme: snapshotHostTheme(),
};
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown);
+9 -4
View File
@@ -18,7 +18,7 @@ import { useEmailStore } from '@/stores/email-store';
import { emailToReadView } from '../plugin-projection';
import { verifyBundle } from './bundle-integrity';
import { downloadManagedBundle } from './bundle-fetch';
import { createBackgroundInstance, forEachInstance } from './host-bridge';
import { createBackgroundInstance, forEachInstance, snapshotRegionalSettings, 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 { cancelPluginDialogs } from './host-api';
@@ -60,13 +60,18 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor
// ─── Locale (kept in step with the app locale) ────────────────
let currentLocale = 'en';
export function setSandboxLocale(locale: string): void {
let currentRegionalKey = '';
export function setSandboxLocale(locale: string, regional?: RegionalSnapshot): 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;
const changed = locale !== currentLocale;
const reg = regional ?? (typeof snapshotRegionalSettings === 'function' ? snapshotRegionalSettings(locale) : undefined);
const regKey = reg ? JSON.stringify(reg) : '';
const changed = locale !== currentLocale || regKey !== currentRegionalKey;
currentLocale = locale;
currentRegionalKey = regKey;
// 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.
@@ -74,7 +79,7 @@ export function setSandboxLocale(locale: string): void {
if (!changed) return;
// Running plugins switch too: their slots re-render in the new language
// and their onLocaleChange handlers run.
forEachInstance(null, (instance) => instance.setLocale(locale));
forEachInstance(null, (instance) => instance.setLocale(locale, reg));
void themeHooks.onLocaleChange.emit({ locale });
}
+23 -1
View File
@@ -11,6 +11,20 @@
import type { SlotName, PluginTier } from '../plugin-types';
import type { ThemeSnapshot } from './host-theme';
/** Resolved presentation/regional settings mirrored into plugin runtimes. */
export interface RegionalSnapshot {
/** The effective UI language, e.g. 'en', 'de', 'fr'. */
locale: string;
/** Resolved BCP-47 tag for date rendering (respects dateLocale override, e.g. 'en-CA' for iso). */
dateLocale: string;
/** True for 12-hour clock (AM/PM), false for 24-hour clock. */
hour12: boolean;
/** The user's effective time zone, e.g. 'Europe/Berlin'. */
timeZone: string;
/** First day of week (0 = Sunday, 1 = Monday, 6 = Saturday). */
firstDayOfWeek: 0 | 1 | 6;
}
/** Initialisation payload for a background-instance iframe (one per plugin). */
export interface BackgroundInit {
mode: 'background';
@@ -34,6 +48,8 @@ export interface BackgroundInit {
code: string;
/** Initial app locale; host pushes updates via 'locale-change'. */
locale: string;
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
regional?: RegionalSnapshot;
}
/** Initialisation payload for a slot-instance iframe (one per slot mount). */
@@ -73,6 +89,8 @@ export interface SlotInit {
*/
extraProps: Record<string, unknown>;
locale: string;
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
regional?: RegionalSnapshot;
/**
* Resolved host theme (colour tokens, font stack, dark flag). The sandbox
* can't load globals.css/fonts cross-origin, so the runtime replays this as
@@ -225,7 +243,11 @@ export interface HookInvokeMsg {
args: unknown[];
}
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
export interface LocaleChangeMsg {
type: 'locale-change';
locale: string;
regional?: RegionalSnapshot;
}
/** Host → sandbox: the resolved theme changed; re-inject the slot's theme CSS. */
export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; }
+59 -4
View File
@@ -26,6 +26,7 @@ import type {
InitPayload,
BackgroundInit,
SlotInit,
RegionalSnapshot,
} from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import { pluginUiKit, SlotPropsContext } from './ui-kit';
@@ -75,13 +76,31 @@ const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err
// place on 'settings-change', so code that kept a reference sees new values.
const settingsState: Record<string, unknown> = {};
// The app language, pushed by the host on init and on every change.
// The app language and regional settings, pushed by the host on init and on every change.
let currentRegional: RegionalSnapshot = {
locale: 'en',
dateLocale: 'en',
hour12: false,
timeZone: 'UTC',
firstDayOfWeek: 1,
};
const localeListeners = new Set<(locale: string) => void>();
function currentLocale(): string {
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
}
function applyLocale(locale: string): void {
function applyLocale(locale: string, regional?: RegionalSnapshot): void {
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = locale;
if (regional) {
currentRegional = regional;
} else {
currentRegional = {
...currentRegional,
locale,
dateLocale: locale,
};
}
document.documentElement.lang = locale;
// Slots sit inside an RTL page in Arabic, Hebrew and Persian; their own
// document has to flip too.
@@ -90,6 +109,25 @@ function applyLocale(locale: string): void {
try { listener(locale); } catch (err) { console.error('[plugin] locale listener failed', err); }
}
}
function formatIntl(date: Date | number | string, options: Intl.DateTimeFormatOptions): string {
try {
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return '';
const locale = currentRegional.dateLocale || currentRegional.locale || 'en';
return new Intl.DateTimeFormat(locale, { timeZone: currentRegional.timeZone, ...options }).format(d);
} catch {
return '';
}
}
function formatTime(date: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
return formatIntl(date, { hour12: currentRegional.hour12, hour: 'numeric', minute: 'numeric', ...options });
}
function formatDate(date: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
return formatIntl(date, options ?? {});
}
const settingsListeners = new Set<(settings: Record<string, unknown>) => void>();
function applySettings(next: Record<string, unknown>): void {
@@ -842,6 +880,23 @@ function buildPluginApi(manifest: PluginManifest) {
return currentLocale();
},
getLocale: () => currentLocale(),
get dateLocale(): string {
return currentRegional.dateLocale;
},
get timeFormat(): '12h' | '24h' {
return currentRegional.hour12 ? '12h' : '24h';
},
get hour12(): boolean {
return currentRegional.hour12;
},
get timeZone(): string {
return currentRegional.timeZone;
},
get firstDayOfWeek(): 0 | 1 | 6 {
return currentRegional.firstDayOfWeek;
},
formatTime,
formatDate,
t(key: string, vars?: Record<string, string | number>): string {
return interpolate(lookup(localeTables, currentLocale(), key) ?? key, vars);
},
@@ -1076,7 +1131,7 @@ async function handleInit(payload: InitPayload): Promise<void> {
mode = payload.mode;
// Make the active locale available to plugin code (api.i18n) right away -
// not only after the first 'locale-change' push.
applyLocale(payload.locale);
applyLocale(payload.locale, payload.regional);
try {
if (payload.mode === 'background') {
await bootBackground(payload);
@@ -1158,7 +1213,7 @@ function handleHostMessage(ev: MessageEvent): void {
}
case 'locale-change':
applyLocale(msg.locale);
applyLocale(msg.locale, msg.regional);
break;
case 'theme-change':
+2 -2
View File
@@ -52,8 +52,8 @@ export function generateUUID(): string {
* - `en-GB` → `en-GB` (DD/MM/YYYY)
* - `en-US` → `en-US` (MM/DD/YYYY)
*/
function resolveDateLocale<T extends string | undefined>(
dateLocale: DateLocale,
export function resolveDateLocale<T extends string | undefined>(
dateLocale: DateLocale | undefined,
fallback: T,
): string | T {
switch (dateLocale) {
+14 -4
View File
@@ -9,6 +9,7 @@ import { pluginStorage } from '@/lib/plugin-storage';
import { extractPlugin } from '@/lib/plugin-validator';
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import { getEffectiveLocale } from '@/i18n/detect-locale';
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
import { requestConsent } from '@/lib/plugin-sandbox/consent';
@@ -26,8 +27,8 @@ import type { Permission } from '@/lib/plugin-types';
import { IS_LITE } from '@/lib/lite';
let pluginInitializationPromise: Promise<void> | null = null;
// One-time guard so we attach the locale->sandbox subscription only once.
let localeSubscribed = false;
// One-time guard so we attach the locale and regional subscriptions only once.
let regionalSubscribed = false;
// ─── Store Interface ─────────────────────────────────────────
@@ -286,9 +287,18 @@ export const usePluginStore = create<PluginStoreState>()(
// the visitor's browser language. Found via a plugin dev report:
// native UI in Hungarian, plugin's own i18n stuck on English.
setSandboxLocale(getEffectiveLocale());
if (!localeSubscribed) {
localeSubscribed = true;
if (!regionalSubscribed) {
regionalSubscribed = true;
useLocaleStore.subscribe(() => setSandboxLocale(getEffectiveLocale()));
let lastKey = `${useSettingsStore.getState().dateLocale}|${useSettingsStore.getState().timeFormat}|${useSettingsStore.getState().timeZone}|${useSettingsStore.getState().firstDayOfWeek}`;
useSettingsStore.subscribe((state) => {
const key = `${state.dateLocale}|${state.timeFormat}|${state.timeZone}|${state.firstDayOfWeek}`;
if (key !== lastKey) {
lastKey = key;
setSandboxLocale(getEffectiveLocale());
}
});
}
// Sync server-managed plugins before loading