feat(plugins): add language packs

This commit is contained in:
Neil
2026-07-13 03:33:43 -07:00
parent 296f0ea5bd
commit f74979ea47
24 changed files with 648 additions and 50 deletions
+28 -1
View File
@@ -17,11 +17,18 @@ import {
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_SPANISH
} from '../../shared/ui-language'
import { ensureMainI18n, setMainUiLanguage, translateMain } from './main-i18n'
import {
ensureMainI18n,
setMainPluginLanguagePacks,
setMainUiLanguage,
translateMain
} from './main-i18n'
import { pluginLanguageResourceId } from '../../shared/plugins/plugin-language-pack-artifact'
describe('main-i18n lazy locale loading', () => {
beforeEach(async () => {
await ensureMainI18n()
setMainPluginLanguagePacks([])
await setMainUiLanguage(UI_LANGUAGE_ENGLISH)
})
@@ -58,4 +65,24 @@ describe('main-i18n lazy locale loading', () => {
await setMainUiLanguage(UI_LANGUAGE_ENGLISH)
expect(translateMain('menu.file', 'File')).toBe('File')
})
it('loads a contributed catalog for native menus and dialogs', async () => {
const id = 'plugin:orca-samples.portuguese/pt-BR' as const
setMainPluginLanguagePacks([
{
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey: 'orca-samples.portuguese',
locale: 'pt-BR',
catalog: { menu: { file: 'Arquivo Orca' } }
}
])
await setMainUiLanguage(id)
expect(translateMain('menu.file', 'File')).toBe('Arquivo Orca')
setMainPluginLanguagePacks([])
expect(await setMainUiLanguage(id)).toBe('en')
expect(translateMain('menu.file', 'File')).toBe('File')
})
})
+33 -2
View File
@@ -9,10 +9,13 @@ import i18next, {
import { isPseudoLocalizationLocale, pseudoLocalizeString } from '../../shared/pseudo-localization'
import { DEFAULT_UI_LOCALE, resolveUiLocale, type SupportedUiLocale } from '../../shared/ui-locale'
import { UI_LANGUAGE_SYSTEM, type UiLanguage } from '../../shared/ui-language'
import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact'
export const mainI18n: I18nInstance = i18next.createInstance()
let initialized = false
let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = []
const registeredPluginLanguages = new Set<string>()
// Why: main-process callers pass English fallbacks to translateMain(), so the
// main bundle does not need to parse any locale catalog at cold start. Only
@@ -72,16 +75,20 @@ export async function ensureMainI18n(): Promise<I18nInstance> {
}
})
initialized = true
applyMainPluginLanguagePacks()
}
return mainI18n
}
export async function setMainUiLanguage(language: UiLanguage): Promise<SupportedUiLocale> {
export async function setMainUiLanguage(language: UiLanguage): Promise<string> {
await ensureMainI18n()
const locale = resolveUiLocale(
const selectedLocale = resolveUiLocale(
language,
language === UI_LANGUAGE_SYSTEM ? getMainSystemLocale() : DEFAULT_UI_LOCALE
)
const locale =
pluginLanguagePacks.find((pack) => pack.id === selectedLocale)?.resourceLanguage ??
(selectedLocale.startsWith('plugin:') ? DEFAULT_UI_LOCALE : selectedLocale)
if (mainI18n.language !== locale) {
// changeLanguage triggers the lazy backend load for non-English locales and
// resolves once the catalog is in memory, so callers that await this have
@@ -91,6 +98,30 @@ export async function setMainUiLanguage(language: UiLanguage): Promise<Supported
return locale
}
function applyMainPluginLanguagePacks(): void {
for (const language of registeredPluginLanguages) {
mainI18n.removeResourceBundle(language, 'translation')
}
registeredPluginLanguages.clear()
for (const pack of pluginLanguagePacks) {
mainI18n.addResourceBundle(pack.resourceLanguage, 'translation', pack.catalog, true, true)
registeredPluginLanguages.add(pack.resourceLanguage)
}
}
export function setMainPluginLanguagePacks(
packs: readonly PluginLanguagePackRegistration[]
): boolean {
if (pluginLanguagePacks === packs) {
return false
}
pluginLanguagePacks = packs
if (initialized) {
applyMainPluginLanguagePacks()
}
return true
}
export function translateMain(key: string, fallback: string, options?: TOptions): string {
// Why: menu registration can run before async init finishes in tests; fall back
// to the English default instead of returning undefined from an uninitialized i18n.
+4 -1
View File
@@ -43,7 +43,7 @@ import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtim
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n'
import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n'
import {
getNextDefaultOnAppearanceSettingValue,
registerAppMenu,
@@ -1964,6 +1964,9 @@ app.whenReady().then(async () => {
console.warn('[plugins] failed to initialize plugin service:', error)
})
pluginService.onChanged(() => {
if (setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? [])) {
void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu())
}
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('plugins:changed')
+5 -1
View File
@@ -105,7 +105,11 @@ export function registerPluginHandlers(
ipcMain.handle('plugins:list', async () => listPluginsForClients(pluginService))
ipcMain.handle('plugins:listThemes', async () => {
await pluginService.whenReady()
return pluginService.themes.list()
return pluginService.contentPacks.themes.list()
})
ipcMain.handle('plugins:listLanguagePacks', async () => {
await pluginService.whenReady()
return pluginService.contentPacks.languagePacks.list()
})
ipcMain.handle('plugins:consent', async (event, args: unknown) => {
@@ -10,7 +10,7 @@ export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024
const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024
export const PLUGIN_THEME_MAX_BYTES = 256 * 1024
const PLUGIN_ICON_THEME_MAX_BYTES = 512 * 1024
const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024
export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024
const PLUGIN_RECIPE_MAX_BYTES = 1024 * 1024
const PLUGIN_AGENT_PROFILE_MAX_BYTES = 1024 * 1024
@@ -0,0 +1,18 @@
export async function mapPluginContentWithConcurrency<T, R>(
values: readonly T[],
concurrency: number,
load: (value: T) => Promise<R>
): Promise<R[]> {
const results: R[] = []
let nextIndex = 0
const worker = async (): Promise<void> => {
while (nextIndex < values.length) {
const index = nextIndex++
results[index] = await load(values[index]!)
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, values.length) }, async () => worker())
)
return results
}
@@ -0,0 +1,28 @@
import type { PluginContentVerifier } from './plugin-content-integrity'
import type { DiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery'
import { PluginLanguagePackRegistry } from './plugin-language-pack-registry'
import { PluginThemeRegistry } from './plugin-theme-registry'
export class PluginContentPackRegistry {
readonly themes: PluginThemeRegistry
readonly languagePacks: PluginLanguagePackRegistry
constructor(contentVerifier: PluginContentVerifier) {
this.themes = new PluginThemeRegistry(contentVerifier)
this.languagePacks = new PluginLanguagePackRegistry(contentVerifier)
}
async reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
): Promise<void> {
await Promise.all([
this.themes.reconcile(discovered, isApproved),
this.languagePacks.reconcile(discovered, isApproved)
])
}
error(pluginKey: string): string | null {
return this.themes.error(pluginKey) ?? this.languagePacks.error(pluginKey)
}
}
@@ -0,0 +1,78 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
import { PluginContentVerifier } from './plugin-content-integrity'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
import { PluginLanguagePackRegistry } from './plugin-language-pack-registry'
const roots: string[] = []
async function pluginWithCatalog(catalog: unknown): Promise<ValidDiscoveredPlugin> {
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-language-registry-'))
roots.push(rootDir)
await mkdir(join(rootDir, 'locales'))
await writeFile(join(rootDir, 'locales', 'pt-BR.json'), JSON.stringify(catalog))
const manifest = pluginManifestSchema.parse({
manifestVersion: 1,
id: 'portuguese',
publisher: 'orca-samples',
name: 'Portuguese',
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes: {
languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }]
},
capabilities: []
})
return {
pluginKey: 'orca-samples.portuguese',
rootDir,
manifest,
consentFingerprint: fingerprintPluginConsent(manifest),
contentHash: null,
isDev: true
}
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('PluginLanguagePackRegistry', () => {
it('loads approved catalogs under an isolated plugin language id', async () => {
const plugin = await pluginWithCatalog({ common: { save: 'Salvar' } })
const registry = new PluginLanguagePackRegistry(new PluginContentVerifier())
await registry.reconcile([plugin], () => true)
expect(registry.list()).toEqual([
{
id: 'plugin:orca-samples.portuguese/pt-BR',
resourceLanguage:
'plugin0070006c007500670069006e003a006f007200630061002d00730061006d0070006c00650073002e0070006f00720074007500670075006500730065002f00700074002d00420052',
pluginKey: 'orca-samples.portuguese',
locale: 'pt-BR',
catalog: { common: { save: 'Salvar' } }
}
])
expect(registry.error(plugin.pluginKey)).toBeNull()
})
it('fails closed for protected security copy and clears state when disabled', async () => {
const plugin = await pluginWithCatalog({
auto: { components: { settings: { PluginConsentDialog: { disclaimer: 'Safe' } } } }
})
const registry = new PluginLanguagePackRegistry(new PluginContentVerifier())
await registry.reconcile([plugin], () => true)
expect(registry.list()).toEqual([])
expect(registry.error(plugin.pluginKey)).toContain('protected security copy')
await registry.reconcile([plugin], () => false)
expect(registry.error(plugin.pluginKey)).toBeNull()
})
})
@@ -0,0 +1,96 @@
import {
parsePluginLanguagePackArtifact,
pluginLanguageResourceId,
type PluginLanguagePackRegistration
} from '../../shared/plugins/plugin-language-pack-artifact'
import {
PLUGIN_LANGUAGE_PACK_MAX_BYTES,
readContainedPluginArtifactText
} from './plugin-artifact-validation'
import type { PluginContentVerifier } from './plugin-content-integrity'
import { mapPluginContentWithConcurrency } from './plugin-content-load-pool'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
const LANGUAGE_PACK_LOAD_CONCURRENCY = 4
type LanguageLoadResult =
| { pluginKey: string; packs: PluginLanguagePackRegistration[] }
| { pluginKey: string; error: string }
export class PluginLanguagePackRegistry {
private packs: PluginLanguagePackRegistration[] = []
private readonly errors = new Map<string, string>()
constructor(private readonly contentVerifier: PluginContentVerifier) {}
list(): readonly PluginLanguagePackRegistration[] {
return this.packs
}
error(pluginKey: string): string | null {
return this.errors.get(pluginKey) ?? null
}
async reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
): Promise<void> {
const candidates: ValidDiscoveredPlugin[] = []
for (const plugin of discovered) {
if (
!isInvalidDiscoveredPlugin(plugin) &&
isApproved(plugin) &&
plugin.manifest.contributes.languagePacks.length > 0
) {
candidates.push(plugin)
}
}
const results = await mapPluginContentWithConcurrency(
candidates,
LANGUAGE_PACK_LOAD_CONCURRENCY,
async (plugin): Promise<LanguageLoadResult> => {
try {
await this.contentVerifier.verify(plugin)
const packs = await Promise.all(
plugin.manifest.contributes.languagePacks.map(async (contribution) => {
const text = await readContainedPluginArtifactText(
plugin.rootDir,
contribution.path,
PLUGIN_LANGUAGE_PACK_MAX_BYTES
)
const parsed = parsePluginLanguagePackArtifact(text)
if (!parsed.ok) {
throw new Error(`language pack "${contribution.locale}" ${parsed.error}`)
}
const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const
return {
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey: plugin.pluginKey,
locale: contribution.locale,
catalog: parsed.catalog
}
})
)
return { pluginKey: plugin.pluginKey, packs }
} catch (error) {
return {
pluginKey: plugin.pluginKey,
error: error instanceof Error ? error.message : String(error)
}
}
}
)
this.packs = results.flatMap((result) => ('packs' in result ? result.packs : []))
this.errors.clear()
for (const result of results) {
if ('error' in result) {
this.errors.set(result.pluginKey, result.error)
}
}
}
}
+7 -5
View File
@@ -33,7 +33,7 @@ import { PluginServiceHousekeeping } from './plugin-service-housekeeping'
import { collectApprovedWorkerSpecs } from './plugin-worker-reconciliation'
import type { PluginRunState } from './plugin-supervisor'
import { isPluginApproved, snapshotPluginConsentLists } from './plugin-activation-policy'
import { PluginThemeRegistry } from './plugin-theme-registry'
import { PluginContentPackRegistry } from './plugin-content-pack-registry'
export type { PluginRuntimeDelegate } from './plugin-host-service-bindings'
export type { PluginLogLine } from './plugin-log-buffer'
@@ -59,7 +59,7 @@ export class PluginService {
private readonly workerController: PluginWorkerController
private readonly logBuffer = new PluginLogBuffer()
private readonly contentVerifier = new PluginContentVerifier()
readonly themes = new PluginThemeRegistry(this.contentVerifier)
readonly contentPacks = new PluginContentPackRegistry(this.contentVerifier)
readonly panels: PluginPanelController
private readonly changeListeners = new Set<() => void>()
private readonly housekeeping = new PluginServiceHousekeeping()
@@ -166,7 +166,9 @@ export class PluginService {
)
// Publish identity before shutdown so triggers cannot restart old code.
this.discovered = next
await this.themes.reconcile(next, (plugin) => isPluginApproved(enabled, plugin, consentLists))
await this.contentPacks.reconcile(next, (plugin) =>
isPluginApproved(enabled, plugin, consentLists)
)
// Notify before slow shutdown so feature-off unmounts panels immediately.
this.notifyChanged()
await this.workerController.reconcile(nextSpecs)
@@ -216,7 +218,7 @@ export class PluginService {
}
activationError(pluginKey: string): string | null {
return this.themes.error(pluginKey) ?? this.workerController.activationError(pluginKey)
return this.contentPacks.error(pluginKey) ?? this.workerController.activationError(pluginKey)
}
/** Consented capability kinds for an approved plugin; null otherwise so
@@ -310,7 +312,7 @@ export class PluginService {
/** Reconciles live workers and client projections after consent or
* enablement changes without re-reading plugin files or starting workers. */
async reconcileActivationState(): Promise<void> {
await this.themes.reconcile(this.discovered, (plugin) => {
await this.contentPacks.reconcile(this.discovered, (plugin) => {
return this.activationState(plugin) === 'approved'
})
const nextSpecs = collectApprovedWorkerSpecs(
+2 -20
View File
@@ -10,6 +10,7 @@ import {
readContainedPluginArtifactText
} from './plugin-artifact-validation'
import type { PluginContentVerifier } from './plugin-content-integrity'
import { mapPluginContentWithConcurrency } from './plugin-content-load-pool'
const THEME_LOAD_CONCURRENCY = 4
@@ -45,7 +46,7 @@ export class PluginThemeRegistry {
candidates.push(plugin)
}
}
const results = await loadWithConcurrency(
const results = await mapPluginContentWithConcurrency(
candidates,
THEME_LOAD_CONCURRENCY,
async (plugin): Promise<ThemeLoadResult> => {
@@ -90,22 +91,3 @@ export class PluginThemeRegistry {
}
}
}
async function loadWithConcurrency<T, R>(
values: readonly T[],
concurrency: number,
load: (value: T) => Promise<R>
): Promise<R[]> {
const results: R[] = []
let nextIndex = 0
const worker = async (): Promise<void> => {
while (nextIndex < values.length) {
const index = nextIndex++
results[index] = await load(values[index]!)
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, values.length) }, async () => worker())
)
return results
}
+2
View File
@@ -55,6 +55,7 @@ import type {
} from '../shared/plugins/plugin-panel-bridge'
import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request'
import type { PluginThemeRegistration } from '../shared/plugins/plugin-theme-artifact'
import type { PluginLanguagePackRegistration } from '../shared/plugins/plugin-language-pack-artifact'
import type {
LocalhostWorktreeLabelResult,
LocalhostWorktreeLabelRoute
@@ -3174,6 +3175,7 @@ export type PreloadApi = {
plugins: {
list: () => Promise<PluginHostListEntry[]>
listThemes: () => Promise<PluginThemeRegistration[]>
listLanguagePacks: () => Promise<PluginLanguagePackRegistration[]>
/** Records the consent-dialog answer; approval is keyed to the plugin's
* current capability and trusted-worker fingerprint. */
consent: (args: PluginConsentRequest) => Promise<PluginHostListEntry[]>
+1
View File
@@ -560,6 +560,7 @@ const api = {
plugins: {
list: (): Promise<PluginHostListEntry[]> => ipcRenderer.invoke('plugins:list'),
listThemes: () => ipcRenderer.invoke('plugins:listThemes'),
listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'),
consent: (args: PluginConsentRequest): Promise<PluginHostListEntry[]> =>
ipcRenderer.invoke('plugins:consent', args),
setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise<PluginHostListEntry[]> =>
@@ -32,6 +32,7 @@ import { translate } from '@/i18n/i18n'
import type { UiLanguage } from '../../../../shared/ui-language'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
import { usePluginThemes } from '@/store/plugin-themes'
import { usePluginLanguagePacks } from '@/store/plugin-language-packs'
type AppearanceInterfaceSectionProps = {
settings: GlobalSettings
@@ -54,6 +55,7 @@ export function AppearanceInterfaceSection({
}: AppearanceInterfaceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const pluginThemes = usePluginThemes()
const pluginLanguagePacks = usePluginLanguagePacks()
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
@@ -239,6 +241,11 @@ export function AppearanceInterfaceSection({
{getUiLanguageChoiceLabel(choice, translate)}
</SelectItem>
))}
{pluginLanguagePacks.map((pack) => (
<SelectItem key={pack.id} value={pack.id}>
{pack.locale} — {pack.pluginKey}
</SelectItem>
))}
</SelectContent>
</Select>
}
+16 -3
View File
@@ -2,8 +2,10 @@ import { useEffect, useRef, type ReactNode } from 'react'
import { I18nextProvider } from 'react-i18next'
import { useAppStore } from '../store'
import { i18n } from './i18n'
import { i18n, setRendererPluginLanguagePacks } from './i18n'
import { resolveUiLocale } from './supported-languages'
import { isPluginUiLanguage } from '../../../shared/ui-language'
import { usePluginLanguagePacks } from '../store/plugin-language-packs'
export function I18nProvider({ children }: { children: ReactNode }): React.JSX.Element {
// Why: settings arrive async over IPC; until they load we must not apply any
@@ -11,9 +13,20 @@ export function I18nProvider({ children }: { children: ReactNode }): React.JSX.E
// changeLanguage that raced with (and could permanently override) the
// persisted preference applied moments later.
const uiLanguage = useAppStore((state) => state.settings?.uiLanguage ?? null)
const locale = uiLanguage === null ? null : resolveUiLocale(uiLanguage)
const pluginLanguagePacks = usePluginLanguagePacks()
const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage)
const locale =
uiLanguage === null
? null
: (selectedPluginLanguage?.resourceLanguage ??
(isPluginUiLanguage(uiLanguage) ? 'en' : resolveUiLocale(uiLanguage)))
const requestedLocale = useRef<string | null>(null)
useEffect(() => {
setRendererPluginLanguagePacks(pluginLanguagePacks)
requestedLocale.current = null
}, [pluginLanguagePacks])
useEffect(() => {
// Why: track the last *requested* locale instead of checking i18n.language —
// an in-flight lazy catalog load leaves i18n.language stale, which made the
@@ -23,7 +36,7 @@ export function I18nProvider({ children }: { children: ReactNode }): React.JSX.E
}
requestedLocale.current = locale
void i18n.changeLanguage(locale)
}, [locale])
}, [locale, pluginLanguagePacks])
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>
}
+29 -2
View File
@@ -10,7 +10,8 @@ import en from './locales/en.json'
import { isPseudoLocalizationLocale, pseudoLocalizeString } from './pseudo-localization'
import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages'
import type { SupportedUiLocale } from '../../../shared/ui-locale'
import type { UiLanguage } from '../../../shared/ui-language'
import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language'
import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact'
export const i18n: I18nInstance = i18next.createInstance()
@@ -78,10 +79,36 @@ export function translate(key: string, fallback: string, options?: TOptions): st
}
export async function setRendererUiLanguage(language: UiLanguage): Promise<void> {
const locale = resolveUiLocale(language)
const resolved = resolveUiLocale(language)
const resourceLanguage = resolveRendererResourceLanguage(resolved)
const locale =
isPluginUiLanguage(language) && resourceLanguage === resolved
? DEFAULT_LOCALE
: resourceLanguage
if (i18n.language !== locale) {
// changeLanguage triggers the lazy backend load for non-English locales and
// resolves once the catalog is in memory.
await i18n.changeLanguage(locale)
}
}
const registeredPluginLanguages = new Set<string>()
let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = []
export function resolveRendererResourceLanguage(language: string): string {
return pluginLanguagePacks.find((pack) => pack.id === language)?.resourceLanguage ?? language
}
export function setRendererPluginLanguagePacks(
packs: readonly PluginLanguagePackRegistration[]
): void {
for (const language of registeredPluginLanguages) {
i18n.removeResourceBundle(language, 'translation')
}
registeredPluginLanguages.clear()
pluginLanguagePacks = packs
for (const pack of packs) {
i18n.addResourceBundle(pack.resourceLanguage, 'translation', pack.catalog, true, true)
registeredPluginLanguages.add(pack.resourceLanguage)
}
}
+24 -1
View File
@@ -5,7 +5,8 @@ import {
UI_LANGUAGE_ENGLISH,
UI_LANGUAGE_SPANISH
} from '../../../shared/ui-language'
import { i18n, setRendererUiLanguage } from './i18n'
import { i18n, setRendererPluginLanguagePacks, setRendererUiLanguage } from './i18n'
import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact'
// Why: the renderer now lazy-loads non-English catalogs through an i18next
// backend instead of bundling all five into the startup chunk. This guards the
@@ -17,6 +18,7 @@ import { i18n, setRendererUiLanguage } from './i18n'
describe('renderer i18n lazy locale loading', () => {
beforeEach(async () => {
setRendererPluginLanguagePacks([])
await i18n.changeLanguage('en')
})
@@ -42,4 +44,25 @@ describe('renderer i18n lazy locale loading', () => {
await setRendererUiLanguage(UI_LANGUAGE_ENGLISH)
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('File')
})
it('loads an isolated catalog contributed by an enabled plugin', async () => {
const id = 'plugin:orca-samples.portuguese/pt-BR' as const
setRendererPluginLanguagePacks([
{
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey: 'orca-samples.portuguese',
locale: 'pt-BR',
catalog: { menu: { file: 'Arquivo Orca' } }
}
])
await setRendererUiLanguage(id)
expect(i18n.language).toBe(pluginLanguageResourceId(id))
expect(i18n.t('menu.file', { defaultValue: 'File' })).toBe('Arquivo Orca')
setRendererPluginLanguagePacks([])
await setRendererUiLanguage(id)
expect(i18n.language).toBe('en')
})
})
+5 -8
View File
@@ -1,8 +1,4 @@
import {
DEFAULT_UI_LOCALE,
resolveRendererUiLocale,
type SupportedUiLocale
} from '../../../shared/ui-locale'
import { DEFAULT_UI_LOCALE, resolveRendererUiLocale } from '../../../shared/ui-locale'
import {
UI_LANGUAGE_CHINESE,
UI_LANGUAGE_ENGLISH,
@@ -10,6 +6,7 @@ import {
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_SPANISH,
UI_LANGUAGE_SYSTEM,
type BuiltInUiLanguage,
type UiLanguage
} from '../../../shared/ui-language'
@@ -18,7 +15,7 @@ export const DEFAULT_LOCALE = DEFAULT_UI_LOCALE
export const SHOW_UI_LANGUAGE_SETTING = true
export type UiLanguageChoice = {
value: UiLanguage
value: BuiltInUiLanguage
labelKey: string
}
@@ -31,7 +28,7 @@ export const UI_LANGUAGE_CHOICES: UiLanguageChoice[] = [
{ value: UI_LANGUAGE_SPANISH, labelKey: 'settings.appearance.language.spanish' }
]
const UI_LANGUAGE_CHOICE_FALLBACKS: Record<UiLanguage, string> = {
const UI_LANGUAGE_CHOICE_FALLBACKS: Record<BuiltInUiLanguage, string> = {
[UI_LANGUAGE_SYSTEM]: 'System',
[UI_LANGUAGE_ENGLISH]: 'English',
[UI_LANGUAGE_CHINESE]: '中文(简体)',
@@ -47,6 +44,6 @@ export function getUiLanguageChoiceLabel(
return translateFn(choice.labelKey, UI_LANGUAGE_CHOICE_FALLBACKS[choice.value])
}
export function resolveUiLocale(language: UiLanguage): SupportedUiLocale {
export function resolveUiLocale(language: UiLanguage): string {
return resolveRendererUiLocale(language)
}
@@ -0,0 +1,54 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact'
type PluginLanguagePackState = {
packs: PluginLanguagePackRegistration[]
loaded: boolean
fetchPacks: () => Promise<void>
}
let requestGeneration = 0
let changeSubscriptionStarted = false
export const usePluginLanguagePackStore = create<PluginLanguagePackState>()((set) => ({
packs: [],
loaded: false,
fetchPacks: async () => {
const generation = ++requestGeneration
const api = window.api?.plugins
if (!api?.listLanguagePacks) {
if (generation === requestGeneration) {
set({ packs: [], loaded: true })
}
return
}
try {
const packs = await api.listLanguagePacks()
if (generation === requestGeneration) {
set({ packs, loaded: true })
}
} catch {
if (generation === requestGeneration) {
set({ packs: [], loaded: true })
}
}
}
}))
export function ensurePluginLanguagePacksLoaded(): void {
const state = usePluginLanguagePackStore.getState()
if (!state.loaded) {
void state.fetchPacks()
}
if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) {
changeSubscriptionStarted = true
window.api.plugins.onChanged(() => void usePluginLanguagePackStore.getState().fetchPacks())
}
}
export function usePluginLanguagePacks(): PluginLanguagePackRegistration[] {
const packs = usePluginLanguagePackStore((state) => state.packs)
useEffect(() => ensurePluginLanguagePacksLoaded(), [])
return packs
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
parsePluginLanguagePackArtifact,
PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH,
pluginLanguageResourceId
} from './plugin-language-pack-artifact'
describe('plugin language-pack artifacts', () => {
it('maps qualified IDs to distinct alphanumeric i18next resource languages', () => {
const first = pluginLanguageResourceId('plugin:a.bc/d-ef')
const second = pluginLanguageResourceId('plugin:ab.c/de-f')
expect(first).toMatch(/^plugin[0-9a-f]+$/)
expect(second).toMatch(/^plugin[0-9a-f]+$/)
expect(first).not.toBe(second)
})
it('accepts nested string catalogs and reports their bounded entry count', () => {
expect(
parsePluginLanguagePackArtifact(
JSON.stringify({
settings: { appearance: { title: 'Aparência' } },
common: { save: 'Salvar' }
})
)
).toEqual({
ok: true,
catalog: {
settings: { appearance: { title: 'Aparência' } },
common: { save: 'Salvar' }
},
entries: 5
})
})
it('prevents language packs from rewriting plugin consent and trust copy', () => {
expect(
parsePluginLanguagePackArtifact(
JSON.stringify({
auto: {
components: {
settings: { PluginConsentDialog: { disclaimer: 'This plugin is perfectly safe.' } }
}
}
})
)
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
})
it.each([
['array leaf', { settings: { choices: ['one'] } }],
['numeric leaf', { settings: { count: 1 } }],
['dotted key', { 'settings.title': 'Title' }],
['prototype key', JSON.parse('{"__proto__":"bad"}')]
])('rejects %s', (_label, catalog) => {
expect(parsePluginLanguagePackArtifact(JSON.stringify(catalog)).ok).toBe(false)
})
it('rejects excessive nesting without recursive validation', () => {
let catalog: Record<string, unknown> = { value: 'deep' }
for (let index = 0; index <= PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH; index += 1) {
catalog = { nested: catalog }
}
expect(parsePluginLanguagePackArtifact(JSON.stringify(catalog))).toMatchObject({
ok: false,
error: expect.stringContaining('depth')
})
})
})
@@ -0,0 +1,111 @@
export const PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES = 20_000
export const PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH = 16
const DANGEROUS_CATALOG_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
const PROTECTED_TRANSLATION_PREFIXES = [
'auto.components.settings.PluginConsentDialog',
'auto.components.settings.PluginInstallDialog',
'auto.components.settings.PluginRemoveDialog',
'auto.components.settings.PluginSettingsRow',
'auto.components.settings.PluginsSettingsSection'
]
export type PluginLanguagePackRegistration = {
id: `plugin:${string}`
resourceLanguage: `plugin${string}`
pluginKey: string
locale: string
catalog: Record<string, unknown>
}
export function pluginLanguageResourceId(id: `plugin:${string}`): `plugin${string}` {
let encoded = ''
for (let index = 0; index < id.length; index += 1) {
encoded += id.charCodeAt(index).toString(16).padStart(4, '0')
}
// Why: i18next parses punctuation in language tags during resource lookup;
// fixed-width UTF-16 hex preserves a collision-free qualified-ID mapping.
return `plugin${encoded}`
}
export type PluginLanguagePackParseResult =
| { ok: true; catalog: Record<string, unknown>; entries: number }
| { ok: false; error: string }
type CatalogFrame = {
source: Record<string, unknown>
target: Record<string, unknown>
path: string
depth: number
}
function isCatalogObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function protectedTranslation(path: string): boolean {
return PROTECTED_TRANSLATION_PREFIXES.some(
(prefix) => path === prefix || path.startsWith(`${prefix}.`)
)
}
function hasUnsafeCatalogKeyCharacter(key: string): boolean {
return key.includes('.') || Array.from(key).some((character) => character.charCodeAt(0) <= 31)
}
export function parsePluginLanguagePackArtifact(raw: string): PluginLanguagePackParseResult {
let json: unknown
try {
json = JSON.parse(raw)
} catch {
return { ok: false, error: 'language pack must contain one JSON object' }
}
if (!isCatalogObject(json)) {
return { ok: false, error: 'language pack root must be an object' }
}
const catalog: Record<string, unknown> = {}
const stack: CatalogFrame[] = [{ source: json, target: catalog, path: '', depth: 0 }]
let entries = 0
while (stack.length > 0) {
const frame = stack.pop()!
if (frame.depth > PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH) {
return { ok: false, error: `catalog exceeds depth ${PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH}` }
}
for (const [key, value] of Object.entries(frame.source)) {
entries += 1
if (entries > PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES) {
return {
ok: false,
error: `catalog exceeds ${PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES} entries`
}
}
if (
key.length === 0 ||
key.length > 128 ||
DANGEROUS_CATALOG_KEYS.has(key) ||
hasUnsafeCatalogKeyCharacter(key)
) {
return { ok: false, error: `catalog key ${key || '(empty)'} is not safe` }
}
const path = frame.path ? `${frame.path}.${key}` : key
if (protectedTranslation(path)) {
return { ok: false, error: `catalog cannot replace protected security copy at ${path}` }
}
if (typeof value === 'string') {
if (value.length > 8192) {
return { ok: false, error: `translation at ${path} exceeds 8192 characters` }
}
frame.target[key] = value
continue
}
if (!isCatalogObject(value)) {
return { ok: false, error: `translation at ${path} must be a string or object` }
}
const child: Record<string, unknown> = {}
frame.target[key] = child
stack.push({ source: value, target: child, path, depth: frame.depth + 1 })
}
}
return { ok: true, catalog, entries }
}
+18 -3
View File
@@ -5,7 +5,7 @@ export const UI_LANGUAGE_KOREAN = 'ko'
export const UI_LANGUAGE_JAPANESE = 'ja'
export const UI_LANGUAGE_SPANISH = 'es'
export type UiLanguage =
export type BuiltInUiLanguage =
| typeof UI_LANGUAGE_SYSTEM
| typeof UI_LANGUAGE_ENGLISH
| typeof UI_LANGUAGE_CHINESE
@@ -13,7 +13,10 @@ export type UiLanguage =
| typeof UI_LANGUAGE_JAPANESE
| typeof UI_LANGUAGE_SPANISH
const UI_LANGUAGE_VALUES = new Set<UiLanguage>([
export type PluginUiLanguage = `plugin:${string}`
export type UiLanguage = BuiltInUiLanguage | PluginUiLanguage
const UI_LANGUAGE_VALUES = new Set<BuiltInUiLanguage>([
UI_LANGUAGE_SYSTEM,
UI_LANGUAGE_ENGLISH,
UI_LANGUAGE_CHINESE,
@@ -22,6 +25,18 @@ const UI_LANGUAGE_VALUES = new Set<UiLanguage>([
UI_LANGUAGE_SPANISH
])
const PLUGIN_UI_LANGUAGE_RE =
/^plugin:[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i
export function isPluginUiLanguage(value: unknown): value is PluginUiLanguage {
return typeof value === 'string' && PLUGIN_UI_LANGUAGE_RE.test(value)
}
export function normalizeUiLanguage(value: unknown): UiLanguage {
return UI_LANGUAGE_VALUES.has(value as UiLanguage) ? (value as UiLanguage) : UI_LANGUAGE_SYSTEM
if (isPluginUiLanguage(value)) {
return value
}
return UI_LANGUAGE_VALUES.has(value as BuiltInUiLanguage)
? (value as BuiltInUiLanguage)
: UI_LANGUAGE_SYSTEM
}
+6
View File
@@ -64,6 +64,12 @@ describe('ui-locale', () => {
expect(resolveUiLocale(UI_LANGUAGE_SPANISH, 'en-US')).toBe('es')
})
it('preserves a selected plugin language bundle id', () => {
expect(resolveUiLocale('plugin:orca-samples.portuguese/pt-BR')).toBe(
'plugin:orca-samples.portuguese/pt-BR'
)
})
it('maps system locale to the closest supported locale', () => {
expect(resolveUiLocale(UI_LANGUAGE_SYSTEM, 'en-GB')).toBe('en')
expect(resolveUiLocale(UI_LANGUAGE_SYSTEM, 'zh-CN')).toBe('zh')
+6 -2
View File
@@ -5,6 +5,7 @@ import {
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_SPANISH,
UI_LANGUAGE_SYSTEM,
isPluginUiLanguage,
type UiLanguage
} from './ui-language'
@@ -34,7 +35,10 @@ export function normalizeSupportedUiLocale(locale: string | undefined): Supporte
export function resolveUiLocale(
language: UiLanguage,
systemLocale: string | undefined = DEFAULT_UI_LOCALE
): SupportedUiLocale {
): string {
if (isPluginUiLanguage(language)) {
return language
}
if (language === UI_LANGUAGE_ENGLISH) {
return DEFAULT_UI_LOCALE
}
@@ -60,7 +64,7 @@ export function getRendererSystemLocale(): string {
return DEFAULT_UI_LOCALE
}
export function resolveRendererUiLocale(language: UiLanguage): SupportedUiLocale {
export function resolveRendererUiLocale(language: UiLanguage): string {
return resolveUiLocale(
language,
language === UI_LANGUAGE_SYSTEM ? getRendererSystemLocale() : DEFAULT_UI_LOCALE