diff --git a/src/main/i18n/main-i18n-lazy-locale.test.ts b/src/main/i18n/main-i18n-lazy-locale.test.ts index 877c36c47e4..30d11f54977 100644 --- a/src/main/i18n/main-i18n-lazy-locale.test.ts +++ b/src/main/i18n/main-i18n-lazy-locale.test.ts @@ -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') + }) }) diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts index 4904260db5c..8ebe21e0545 100644 --- a/src/main/i18n/main-i18n.ts +++ b/src/main/i18n/main-i18n.ts @@ -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() // 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 { } }) initialized = true + applyMainPluginLanguagePacks() } return mainI18n } -export async function setMainUiLanguage(language: UiLanguage): Promise { +export async function setMainUiLanguage(language: UiLanguage): Promise { 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 { 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') diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 13fbc028f1c..1d73294811e 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -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) => { diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts index 4f589e2ed60..9270a7ce1f4 100644 --- a/src/main/plugins/plugin-artifact-validation.ts +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -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 diff --git a/src/main/plugins/plugin-content-load-pool.ts b/src/main/plugins/plugin-content-load-pool.ts new file mode 100644 index 00000000000..6b404161476 --- /dev/null +++ b/src/main/plugins/plugin-content-load-pool.ts @@ -0,0 +1,18 @@ +export async function mapPluginContentWithConcurrency( + values: readonly T[], + concurrency: number, + load: (value: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const worker = async (): Promise => { + 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 +} diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts new file mode 100644 index 00000000000..6f305dcea0e --- /dev/null +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -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 { + 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) + } +} diff --git a/src/main/plugins/plugin-language-pack-registry.test.ts b/src/main/plugins/plugin-language-pack-registry.test.ts new file mode 100644 index 00000000000..94fd93e45ff --- /dev/null +++ b/src/main/plugins/plugin-language-pack-registry.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/main/plugins/plugin-language-pack-registry.ts b/src/main/plugins/plugin-language-pack-registry.ts new file mode 100644 index 00000000000..dcdaed176ce --- /dev/null +++ b/src/main/plugins/plugin-language-pack-registry.ts @@ -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() + + 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 { + 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 => { + 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) + } + } + } +} diff --git a/src/main/plugins/plugin-service.ts b/src/main/plugins/plugin-service.ts index d4dcf2b29bc..d4762e19e72 100644 --- a/src/main/plugins/plugin-service.ts +++ b/src/main/plugins/plugin-service.ts @@ -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 { - await this.themes.reconcile(this.discovered, (plugin) => { + await this.contentPacks.reconcile(this.discovered, (plugin) => { return this.activationState(plugin) === 'approved' }) const nextSpecs = collectApprovedWorkerSpecs( diff --git a/src/main/plugins/plugin-theme-registry.ts b/src/main/plugins/plugin-theme-registry.ts index 0ff859a9b3d..a757f001ede 100644 --- a/src/main/plugins/plugin-theme-registry.ts +++ b/src/main/plugins/plugin-theme-registry.ts @@ -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 => { @@ -90,22 +91,3 @@ export class PluginThemeRegistry { } } } - -async function loadWithConcurrency( - values: readonly T[], - concurrency: number, - load: (value: T) => Promise -): Promise { - const results: R[] = [] - let nextIndex = 0 - const worker = async (): Promise => { - 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 -} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4fd1e9b0c0d..09597933a2c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 listThemes: () => Promise + listLanguagePacks: () => Promise /** Records the consent-dialog answer; approval is keyed to the plugin's * current capability and trusted-worker fingerprint. */ consent: (args: PluginConsentRequest) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 7db2804e41b..f618e39bb06 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -560,6 +560,7 @@ const api = { plugins: { list: (): Promise => ipcRenderer.invoke('plugins:list'), listThemes: () => ipcRenderer.invoke('plugins:listThemes'), + listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), consent: (args: PluginConsentRequest): Promise => ipcRenderer.invoke('plugins:consent', args), setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 47ee266a51c..3caa1aa39df 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -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)} ))} + {pluginLanguagePacks.map((pack) => ( + + {pack.locale} — {pack.pluginKey} + + ))} } diff --git a/src/renderer/src/i18n/I18nProvider.tsx b/src/renderer/src/i18n/I18nProvider.tsx index 5cfd2c04206..4eab2d543ee 100644 --- a/src/renderer/src/i18n/I18nProvider.tsx +++ b/src/renderer/src/i18n/I18nProvider.tsx @@ -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(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 {children} } diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index bc3287c55b0..3f35e2ec224 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -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 { - 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() +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) + } +} diff --git a/src/renderer/src/i18n/lazy-locale.test.ts b/src/renderer/src/i18n/lazy-locale.test.ts index 0795a52f6c0..87c6e955977 100644 --- a/src/renderer/src/i18n/lazy-locale.test.ts +++ b/src/renderer/src/i18n/lazy-locale.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/i18n/supported-languages.ts b/src/renderer/src/i18n/supported-languages.ts index 56d74726109..446a3a0419c 100644 --- a/src/renderer/src/i18n/supported-languages.ts +++ b/src/renderer/src/i18n/supported-languages.ts @@ -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 = { +const UI_LANGUAGE_CHOICE_FALLBACKS: Record = { [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) } diff --git a/src/renderer/src/store/plugin-language-packs.ts b/src/renderer/src/store/plugin-language-packs.ts new file mode 100644 index 00000000000..30f64be3fef --- /dev/null +++ b/src/renderer/src/store/plugin-language-packs.ts @@ -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 +} + +let requestGeneration = 0 +let changeSubscriptionStarted = false + +export const usePluginLanguagePackStore = create()((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 +} diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts new file mode 100644 index 00000000000..c685dd99f53 --- /dev/null +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -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 = { 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') + }) + }) +}) diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts new file mode 100644 index 00000000000..ab5e8400041 --- /dev/null +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -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 +} + +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; entries: number } + | { ok: false; error: string } + +type CatalogFrame = { + source: Record + target: Record + path: string + depth: number +} + +function isCatalogObject(value: unknown): value is Record { + 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 = {} + 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 = {} + frame.target[key] = child + stack.push({ source: value, target: child, path, depth: frame.depth + 1 }) + } + } + return { ok: true, catalog, entries } +} diff --git a/src/shared/ui-language.ts b/src/shared/ui-language.ts index 9a2f7a4a028..77002e81f89 100644 --- a/src/shared/ui-language.ts +++ b/src/shared/ui-language.ts @@ -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([ +export type PluginUiLanguage = `plugin:${string}` +export type UiLanguage = BuiltInUiLanguage | PluginUiLanguage + +const UI_LANGUAGE_VALUES = new Set([ UI_LANGUAGE_SYSTEM, UI_LANGUAGE_ENGLISH, UI_LANGUAGE_CHINESE, @@ -22,6 +25,18 @@ const UI_LANGUAGE_VALUES = new Set([ 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 } diff --git a/src/shared/ui-locale.test.ts b/src/shared/ui-locale.test.ts index 90ac28ab7d5..272506e26a9 100644 --- a/src/shared/ui-locale.test.ts +++ b/src/shared/ui-locale.test.ts @@ -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') diff --git a/src/shared/ui-locale.ts b/src/shared/ui-locale.ts index 4435ef14e5f..bc84491b7b2 100644 --- a/src/shared/ui-locale.ts +++ b/src/shared/ui-locale.ts @@ -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