Files
orca/src/shared/plugins/plugin-language-pack-artifact.ts
T
OrcaWinandOrcaWin 95a83ccc6c fix(i18n): native plural rules for plugin packs in main, eager main init (#35403)
- Plugin pack i18next resource IDs become BCP-47 private-use tags on the
  pack's base locale (ru-RU-x-orcapk-...), so Intl and i18next pick the
  pack's plural rules natively in main and renderer. Drops the renderer
  pluralResolver.getRule patch; pack registration is shared. Persisted
  uiLanguage and pack ids keep the plugin: form.
- returnEmptyString: false in both instances; main no longer returns the
  raw uninterpolated fallback for an empty translation.
- Main i18n initializes at module load (synchronous with in-memory
  resources), removing the initialized flag and interpolateFallback.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 15:18:31 -07:00

206 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
translatablePluginChrome,
translatablePluginChromeContainer
} from './plugin-translatable-chrome'
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'])
// Protect plugin security copy across the whole namespace so extracted/new dialogs cannot become writable.
// Exact nonsecurity chrome exemptions live in plugin-translatable-chrome.ts.
const PROTECTED_TRANSLATION_ROOT = 'auto.components.settings.'
const PROTECTED_TRANSLATION_MODULE = /^plugin/i
export type PluginLanguagePackRegistration = {
id: `plugin:${string}`
resourceLanguage: string
pluginKey: string
locale: string
displayName?: string
catalog: Record<string, unknown>
}
export function isPluginLanguagePackRegistration(
value: unknown
): value is PluginLanguagePackRegistration {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false
}
const pack = value as Record<string, unknown>
return (
typeof pack.id === 'string' &&
pack.id.startsWith('plugin:') &&
typeof pack.resourceLanguage === 'string' &&
pack.resourceLanguage === pluginLanguageResourceId(pack.id as `plugin:${string}`) &&
typeof pack.pluginKey === 'string' &&
typeof pack.locale === 'string' &&
(pack.displayName === undefined || typeof pack.displayName === 'string') &&
checkPluginLanguagePackCatalog(pack.catalog).ok
)
}
// Internal i18next ID only; settings and IPC keep the qualified `plugin:` ID.
export function pluginLanguageResourceId(id: `plugin:${string}`): string {
// Fixed-width UTF-8 hex keeps IDs collision-free; private-use subtags allow at most 8 chars.
const encoded = Array.from(new TextEncoder().encode(id.slice('plugin:'.length)), (byte) =>
byte.toString(16).padStart(2, '0')
).join('')
const subtags = encoded.match(/.{1,8}/g) ?? []
// The real base locale lets Intl and i18next pick the pack's plural rules natively.
return `${pluginPackBaseLocale(id.slice(id.lastIndexOf('/') + 1))}-x-orcapk-${subtags.join('-')}`
}
function pluginPackBaseLocale(locale: string): string {
try {
const supported = Intl.PluralRules.supportedLocalesOf(locale)[0]
// Unsupported tags would get the runtime's plural rules; use English like other unknown locales.
return supported ? new Intl.Locale(supported).baseName : 'en'
} catch {
return 'en'
}
}
export type PluginLanguagePackParseResult =
| { ok: true; catalog: Record<string, unknown>; entries: number }
| { ok: false; error: string }
export type PluginLanguagePackValidationResult =
| { ok: true; entries: number }
| { ok: false; error: string }
type PluginLanguagePackWalkResult =
| { ok: true; catalog: Record<string, unknown> | null; entries: number }
| { ok: false; error: string }
type CatalogFrame = {
source: Record<string, unknown>
target: Record<string, unknown> | null
path: string
depth: number
}
export function isCatalogObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
)
}
function protectedTranslation(path: string): boolean {
if (!path.startsWith(PROTECTED_TRANSLATION_ROOT)) {
return false
}
if (translatablePluginChrome(path)) {
return false
}
return PROTECTED_TRANSLATION_MODULE.test(path.slice(PROTECTED_TRANSLATION_ROOT.length))
}
function hasUnsafeCatalogKeyCharacter(key: string): boolean {
if (key.includes('.')) {
return true
}
for (let index = 0; index < key.length; index += 1) {
if (key.charCodeAt(index) <= 31) {
return true
}
}
return false
}
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' }
}
return validatePluginLanguagePackCatalog(json)
}
export function validatePluginLanguagePackCatalog(source: unknown): PluginLanguagePackParseResult {
const result = walkPluginLanguagePackCatalog(source, true)
if (!result.ok) {
return result
}
return { ok: true, catalog: result.catalog!, entries: result.entries }
}
export function checkPluginLanguagePackCatalog(
source: unknown
): PluginLanguagePackValidationResult {
const result = walkPluginLanguagePackCatalog(source, false)
return result.ok ? { ok: true, entries: result.entries } : result
}
function walkPluginLanguagePackCatalog(
source: unknown,
copyCatalog: boolean
): PluginLanguagePackWalkResult {
if (!isCatalogObject(source)) {
return { ok: false, error: 'language pack root must be an object' }
}
const catalog: Record<string, unknown> | null = copyCatalog ? {} : null
const stack: CatalogFrame[] = [{ source, target: catalog, path: '', depth: 0 }]
const seen = new WeakSet<object>([source])
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 of Object.keys(frame.source)) {
const value = frame.source[key]
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
// Walk protected containers only to reach exempt chrome; still check every leaf’s full path.
if (
protectedTranslation(path) &&
!(isCatalogObject(value) && translatablePluginChromeContainer(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` }
}
if (frame.target) {
frame.target[key] = value
}
continue
}
if (!isCatalogObject(value)) {
return { ok: false, error: `translation at ${path} must be a string or object` }
}
if (seen.has(value)) {
return { ok: false, error: `catalog contains a repeated or cyclic object at ${path}` }
}
seen.add(value)
const child: Record<string, unknown> | null = frame.target ? {} : null
if (frame.target && child) {
frame.target[key] = child
}
stack.push({ source: value, target: child, path, depth: frame.depth + 1 })
}
}
return { ok: true, catalog, entries }
}