fix: switch running plugins to a new language right away and fall back from de-CH to de

This commit is contained in:
Linus Rath
2026-10-14 02:23:40 +02:00
parent 8f70951555
commit a21dbc92ec
5 changed files with 134 additions and 19 deletions
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { lookup, interpolate, localeChain } from '../plugin-sandbox/plugin-i18n';
const tables = {
en: { hello: 'Hello {name}', only_en: 'English only' },
de: { hello: 'Hallo {name}' },
'zh-TW': { hello: '你好 {name}' },
};
describe('plugin translations', () => {
it('falls back from a regional locale to its language', () => {
// de-CH used to skip straight to English.
expect(lookup(tables, 'de-CH', 'hello')).toBe('Hallo {name}');
});
it('matches locale names regardless of case', () => {
expect(lookup(tables, 'zh-tw', 'hello')).toBe('你好 {name}');
});
it('falls back to English, then reports a missing key', () => {
expect(lookup(tables, 'de', 'only_en')).toBe('English only');
expect(lookup(tables, 'de', 'missing')).toBeUndefined();
});
it('builds the chain most specific first', () => {
expect(localeChain('pt-BR')).toEqual(['pt-BR', 'pt', 'en']);
});
it('fills placeholders', () => {
expect(interpolate('Hallo {name}', { name: 'Ana' })).toBe('Hallo Ana');
});
});
+15 -1
View File
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
downloadManagedBundle: vi.fn(),
createBackgroundInstance: vi.fn(),
registerShortcuts: vi.fn((..._args: unknown[]) => () => {}),
setLocale: vi.fn(),
}));
vi.mock('../plugin-storage', () => ({
@@ -22,6 +23,7 @@ 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 }),
SandboxInstance: class {},
}));
vi.mock('../plugin-sandbox/host-api', () => ({
@@ -31,7 +33,7 @@ vi.mock('../plugin-sandbox/shortcuts', () => ({
registerShortcuts: (...args: unknown[]) => mocks.registerShortcuts(...args),
}));
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader';
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor, setSandboxLocale } from '../plugin-sandbox/loader';
import { get as getActive } from '../plugin-sandbox/registry';
const CODE = 'export default { activate() {} }';
@@ -205,3 +207,15 @@ describe('plugin shortcuts', () => {
expect(mocks.registerShortcuts).toHaveBeenCalledWith(expect.anything(), SHORTCUT);
});
});
describe('setSandboxLocale', () => {
it('switches running plugins to the new language', () => {
// Running plugins used to keep the old language until a reload.
setSandboxLocale('en');
mocks.setLocale.mockClear();
setSandboxLocale('de');
expect(mocks.setLocale).toHaveBeenCalledWith('de');
});
});
+8 -3
View File
@@ -16,7 +16,7 @@ import {
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
import { verifyBundle } from './bundle-integrity';
import { downloadManagedBundle } from './bundle-fetch';
import { createBackgroundInstance } from './host-bridge';
import { createBackgroundInstance, forEachInstance } from './host-bridge';
import { resolvePluginTier } from './tier';
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
import { cancelPluginDialogs } from './host-api';
@@ -51,12 +51,17 @@ export function setSandboxLocale(locale: string): void {
// 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;
currentLocale = locale;
// 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.
// both in step from one place.
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale;
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));
void themeHooks.onLocaleChange.emit({ locale });
}
// ─── Bundle fetch ─────────────────────────────────────────────
+40
View File
@@ -0,0 +1,40 @@
// Translation lookup for plugin strings (api.i18n.t). Pure, so the runtime
// inside the sandbox and the tests share it.
export type LocaleTables = Record<string, Record<string, string>>;
/**
* Locales to try for `locale`, most specific first: `de-CH` → `de-CH`, `de`,
* then `en`. Matching ignores case (`zh-tw` finds `zh-TW`).
*/
export function localeChain(locale: string): string[] {
const chain: string[] = [];
const parts = (locale || 'en').split('-');
for (let i = parts.length; i > 0; i--) chain.push(parts.slice(0, i).join('-'));
if (!chain.includes('en')) chain.push('en');
return chain;
}
function findTable(tables: LocaleTables, locale: string): Record<string, string> | undefined {
if (tables[locale]) return tables[locale];
const lower = locale.toLowerCase();
const key = Object.keys(tables).find((k) => k.toLowerCase() === lower);
return key ? tables[key] : undefined;
}
/** The text for `key`, or undefined when no table has it. */
export function lookup(tables: LocaleTables, locale: string, key: string): string | undefined {
for (const loc of localeChain(locale)) {
const value = findTable(tables, loc)?.[key];
if (typeof value === 'string') return value;
}
return undefined;
}
/** Replace `{name}` placeholders. */
export function interpolate(text: string, vars?: Record<string, string | number>): string {
if (!vars) return text;
let out = text;
for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v));
return out;
}
+39 -15
View File
@@ -28,6 +28,7 @@ import type {
SlotInit,
} from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import { interpolate, lookup, type LocaleTables } from './plugin-i18n';
import type { SlotName } from '../plugin-types';
import { AddressBook, ContactCard } from '../jmap/types';
import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store';
@@ -66,6 +67,19 @@ const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err
// The plugin's settings. One object for the iframe's lifetime, updated in
// 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.
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 {
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = locale;
document.documentElement.lang = locale;
for (const listener of localeListeners) {
try { listener(locale); } catch (err) { console.error('[plugin] locale listener failed', err); }
}
}
const settingsListeners = new Set<(settings: Record<string, unknown>) => void>();
function applySettings(next: Record<string, unknown>): void {
@@ -229,6 +243,7 @@ interface PluginKeywordCounts {
function buildPluginApi(manifest: PluginManifest) {
applySettings({ ...manifest.settings });
const localeTables: LocaleTables = JSON.parse(JSON.stringify(manifest.locales ?? {}));
return {
plugin: {
id: manifest.id,
@@ -495,22 +510,27 @@ function buildPluginApi(manifest: PluginManifest) {
},
// 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.
// `locales` map (manifest.locales) plus anything added at runtime,
// trying `de-CH`, then `de`, then English, then the key itself, with
// optional {placeholder} interpolation.
i18n: {
get locale(): string {
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
return currentLocale();
},
getLocale: () => currentLocale(),
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;
return interpolate(lookup(localeTables, currentLocale(), key) ?? key, vars);
},
/** Whether any table has `key`, for `has(k) ? t(k) : fallback`. */
has: (key: string) => lookup(localeTables, currentLocale(), key) !== undefined,
/** Add or override strings for one locale. */
addTranslations(locale: string, strings: Record<string, string>): void {
localeTables[locale] = { ...(localeTables[locale] ?? {}), ...strings };
},
/** Called with the new locale when the user switches language. Returns an unsubscribe function. */
onLocaleChange: (listener: (locale: string) => void) => {
localeListeners.add(listener);
return () => { localeListeners.delete(listener); };
},
},
};
@@ -664,7 +684,11 @@ function bootSlot(payload: SlotInit): void {
React.useEffect(() => {
const bump = () => setSettingsVersion((v) => v + 1);
settingsListeners.add(bump);
return () => { settingsListeners.delete(bump); };
localeListeners.add(bump);
return () => {
settingsListeners.delete(bump);
localeListeners.delete(bump);
};
}, []);
React.useEffect(() => {
if (!wrapRef.current) return;
@@ -698,7 +722,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.
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale;
applyLocale(payload.locale);
try {
if (payload.mode === 'background') {
await bootBackground(payload);
@@ -780,7 +804,7 @@ function handleHostMessage(ev: MessageEvent): void {
}
case 'locale-change':
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
applyLocale(msg.locale);
break;
case 'theme-change':