mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
feat(plugins): add language packs
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user