diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b44287a9e18..711793ac102 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,9 @@ # Product source remains owned by its normal reviewers; localization inputs need focused review. /src/renderer/src/i18n/locales/ @brennanb2025 +/mobile/src/i18n/locales/ @brennanb2025 +/mobile/locales/ @brennanb2025 /config/scripts/*localization*.mjs @brennanb2025 /config/scripts/*locale*.mjs @brennanb2025 +/config/scripts/localize-mobile-strings.mjs @brennanb2025 +/config/scripts/mobile-embedded-document-candidates.mjs @brennanb2025 /config/i18next.config.ts @brennanb2025 diff --git a/config/localization-audit.md b/config/localization-audit.md index a7e5b2e9f24..154c6fe55c5 100644 --- a/config/localization-audit.md +++ b/config/localization-audit.md @@ -53,15 +53,16 @@ The gate scans the desktop renderer plus both mobile source roots. Mobile uses `config/mobile-localization-coverage-allowlist.json`, which is also committed empty. -Verify mobile catalog references, conditional keys, interpolation options, and -the translations present in each sparse locale: +Verify mobile catalog references, conditional keys, interpolation options, +protected technical literals, and non-empty translations. The same gate checks +Expo native locale mappings, required permission keys, and English fallbacks: ```sh pnpm run verify:mobile-localization-catalog ``` -The checked-in mobile runtime JSON is a temporary bridge to the planned -canonical PO source and read-only compiler. Feature work updates English only; +The checked-in mobile runtime JSON is a temporary bridge to the canonical PO +source and read-only compiler. Feature work updates English only; missing target values intentionally use runtime English fallback. Whole-catalog translation commands are not part of the maintained workflow. diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index 3d9dfc58ecd..0b5fdae58a6 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -6,6 +6,7 @@ import process from 'node:process' // TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. import ts from 'typescript-api' +import { collectMobileEmbeddedDocumentCandidates } from './mobile-embedded-document-candidates.mjs' import { classifyMobileStringNode } from './mobile-localization-candidate-rules.mjs' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) @@ -444,6 +445,23 @@ export function collectLocalizationCandidates(filePath, sourceText, root = proce } visit(sourceFile) + if (mobileSource) { + for (const candidate of collectMobileEmbeddedDocumentCandidates(relativePath, sourceText)) { + const value = compactText(candidate.text) + if (!hasHumanLanguageText(value)) { + continue + } + const position = sourceFile.getLineAndCharacterOfPosition(candidate.start) + reports.push({ + area: areaForFile(relativePath), + filePath: relativePath, + ...candidate, + line: position.line + 1, + column: position.character + 1, + text: value + }) + } + } return reports } diff --git a/config/scripts/mobile-embedded-document-candidates.mjs b/config/scripts/mobile-embedded-document-candidates.mjs new file mode 100644 index 00000000000..06e23ae0852 --- /dev/null +++ b/config/scripts/mobile-embedded-document-candidates.mjs @@ -0,0 +1,100 @@ +// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. +import ts from 'typescript-api' + +const EMBEDDED_DOCUMENT_FILE_RE = /(?:-html|webview-html)\.[cm]?[jt]sx?$/ +const USER_VISIBLE_ATTRIBUTE_RE = + /\b(aria-description|aria-label|alt|data-placeholder|placeholder|title)\s*=\s*(["'])(.*?)\2/gi +const USER_VISIBLE_PROMPT_RE = /\b(?:window\.)?(alert|confirm|prompt)\(\s*(["'])(.*?)\2/g +const INSERTED_HTML_RE = + /\bexecCommand\(\s*(["'])insertHTML\1\s*,\s*false\s*,\s*(["'])([\s\S]*?)\2\s*\)/g + +function maskNonMarkupContent(documentText) { + return documentText.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, (value) => + value.replace(/[^\n]/g, ' ') + ) +} + +function capturedOffset(match, capturedValue) { + return (match.index ?? 0) + match[0].indexOf(capturedValue) +} + +function collectMarkupCandidates(markup, baseOffset, candidates) { + for (const match of markup.matchAll(USER_VISIBLE_ATTRIBUTE_RE)) { + const value = match[3] + if (!value.includes('${')) { + const start = baseOffset + capturedOffset(match, value) + candidates.push({ + start, + end: start + value.length, + kind: `embedded-html-attribute:${match[1].toLowerCase()}`, + text: value, + dynamic: false + }) + } + } + + for (const match of markup.matchAll(/>([^<]+) candidate.text)).toEqual(['repository', 'repositories']) }) + + it('finds copy embedded in mobile WebView documents', () => { + const source = String.raw` +export const HTML = \` + + + +\` +` + const candidates = collectLocalizationCandidates( + '/repo/mobile/src/components/example-html.ts', + source, + '/repo' + ) + + expect(candidates.map((candidate) => candidate.text)).toEqual([ + 'Start writing...', + 'Copy', + 'Select All', + 'Link URL', + 'Task' + ]) + }) }) diff --git a/config/scripts/verify-mobile-localization-catalog.mjs b/config/scripts/verify-mobile-localization-catalog.mjs index e423fd350c6..9019733f8b4 100644 --- a/config/scripts/verify-mobile-localization-catalog.mjs +++ b/config/scripts/verify-mobile-localization-catalog.mjs @@ -10,6 +10,44 @@ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']) const SOURCE_RELATIVE_ROOTS = [path.join('mobile', 'app'), path.join('mobile', 'src')] const LOCALES_RELATIVE_DIR = path.join('mobile', 'src', 'i18n', 'locales') const MOBILE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh'] +const NATIVE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh-Hans'] +const NATIVE_LOCALES_RELATIVE_DIR = path.join('mobile', 'locales') +const NATIVE_REQUIRED_KEYS = [ + 'ios.CFBundleDisplayName', + 'ios.NSCameraUsageDescription', + 'ios.NSLocalNetworkUsageDescription', + 'ios.NSMicrophoneUsageDescription', + 'ios.NSPhotoLibraryUsageDescription', + 'android.app_name' +] +const NATIVE_LOCALE_PATHS = new Map( + NATIVE_LOCALES.map((locale) => [locale, `./locales/${locale}.json`]) +) +const RUNTIME_TO_NATIVE_LOCALE = new Map([ + ['en', 'en'], + ['es', 'es'], + ['ja', 'ja'], + ['ko', 'ko'], + ['zh', 'zh-Hans'] +]) +const PROTECTED_LITERALS = [ + 'sudo ufw allow 6768', + 'npm run dev', + 'pnpm build', + 'stablyai/orca', + 'onOrca.dev', + 'orca.yaml', + 'Tailscale', + 'GitHub', + 'GitLab', + 'Codex', + 'Claude', + 'Orca', + 'WSL', + 'SSH', + 'HEAD', + 'MR !' +] const PLACEHOLDER_RE = /\{\{\s*([^,}\s]+)(?:,[^}]*)?\}\}/g const ENCODED_HTML_ENTITY_RE = /&(?:amp|apos|gt|lt|quot);/i @@ -45,6 +83,9 @@ async function collectSourceFiles(directory) { function flattenCatalog(value, catalogName, prefix = '', entries = new Map(), issues = []) { if (typeof value === 'string') { entries.set(prefix, value) + if (value.trim().length === 0) { + issues.push(`${catalogName}: ${prefix} must not be empty`) + } if (ENCODED_HTML_ENTITY_RE.test(value)) { issues.push(`${catalogName}: ${prefix} contains an encoded HTML entity`) } @@ -138,6 +179,20 @@ function sameValues(left, right) { return left.length === right.length && left.every((value, index) => value === right[index]) } +function occurrenceCount(value, literal) { + return value.split(literal).length - 1 +} + +function verifyProtectedLiterals(englishValue, localeValue, locale, key) { + const issues = [] + for (const literal of PROTECTED_LITERALS) { + if (occurrenceCount(localeValue, literal) < occurrenceCount(englishValue, literal)) { + issues.push(`${locale}.json protected literal mismatch: ${key} must preserve ${literal}`) + } + } + return issues +} + export function collectMobileTranslationCalls(filePath, sourceText, root = process.cwd()) { const sourceKind = filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS @@ -216,15 +271,129 @@ function verifyLocaleEntries(englishEntries, locale, localeEntries) { continue } const englishPlaceholders = collectPlaceholderNames(englishValue) - const localePlaceholders = collectPlaceholderNames(localeEntries.get(key)) + const localeValue = localeEntries.get(key) + const localePlaceholders = collectPlaceholderNames(localeValue) if (!sameValues(englishPlaceholders, localePlaceholders)) { issues.push(`${locale}.json placeholder mismatch: ${key}`) } + issues.push(...verifyProtectedLiterals(englishValue, localeValue, locale, key)) } return issues } +function findPluginOptions(expoConfig, pluginName) { + const plugin = expoConfig.plugins?.find( + (entry) => Array.isArray(entry) && entry[0] === pluginName + ) + return Array.isArray(plugin) && typeof plugin[1] === 'object' && plugin[1] !== null + ? plugin[1] + : {} +} + +function verifyNativeFallbacks(expoConfig, englishEntries) { + const camera = findPluginOptions(expoConfig, 'expo-camera') + const imagePicker = findPluginOptions(expoConfig, 'expo-image-picker') + const infoPlist = expoConfig.ios?.infoPlist ?? {} + const fallbackValues = new Map([ + ['ios.CFBundleDisplayName', [expoConfig.name]], + ['ios.NSCameraUsageDescription', [camera.cameraPermission]], + ['ios.NSLocalNetworkUsageDescription', [infoPlist.NSLocalNetworkUsageDescription]], + [ + 'ios.NSMicrophoneUsageDescription', + [infoPlist.NSMicrophoneUsageDescription, camera.microphonePermission] + ], + [ + 'ios.NSPhotoLibraryUsageDescription', + [infoPlist.NSPhotoLibraryUsageDescription, imagePicker.photosPermission] + ], + ['android.app_name', [expoConfig.name]] + ]) + const issues = [] + + for (const [key, values] of fallbackValues) { + const englishValue = englishEntries.get(key) + for (const value of values) { + if (typeof value !== 'string' || value !== englishValue) { + issues.push(`mobile/app.json English native fallback mismatch: ${key}`) + break + } + } + } + return issues +} + +async function verifyNativeCatalogs(root) { + const issues = [] + const appConfigPath = path.join(root, 'mobile', 'app.json') + let appConfig + try { + appConfig = JSON.parse(await fs.readFile(appConfigPath, 'utf8')) + } catch (error) { + return [ + `${normalizePath(root, appConfigPath)} could not be read: ${error instanceof Error ? error.message : String(error)}` + ] + } + + const expoConfig = appConfig.expo ?? {} + const configuredLocales = expoConfig.locales ?? {} + for (const [locale, expectedPath] of NATIVE_LOCALE_PATHS) { + if (configuredLocales[locale] !== expectedPath) { + issues.push(`mobile/app.json locale ${locale} must map to ${expectedPath}`) + } + } + for (const locale of Object.keys(configuredLocales)) { + if (!NATIVE_LOCALE_PATHS.has(locale)) { + issues.push(`mobile/app.json has unsupported native locale mapping: ${locale}`) + } + } + + const localizationPlugin = findPluginOptions(expoConfig, 'expo-localization') + if (!sameValues(localizationPlugin.supportedLocales ?? [], NATIVE_LOCALES)) { + issues.push('mobile/app.json expo-localization supportedLocales mismatch') + } + for (const [runtimeLocale, nativeLocale] of RUNTIME_TO_NATIVE_LOCALE) { + if (!MOBILE_LOCALES.includes(runtimeLocale) || !NATIVE_LOCALE_PATHS.has(nativeLocale)) { + issues.push(`mobile locale mapping is incomplete: ${runtimeLocale} -> ${nativeLocale}`) + } + } + + const nativeCatalogs = new Map() + for (const locale of NATIVE_LOCALES) { + const catalogPath = path.join(root, NATIVE_LOCALES_RELATIVE_DIR, `${locale}.json`) + try { + const flattened = flattenCatalog( + JSON.parse(await fs.readFile(catalogPath, 'utf8')), + `mobile/locales/${locale}.json` + ) + nativeCatalogs.set(locale, flattened.entries) + issues.push(...flattened.issues) + } catch (error) { + issues.push( + `${normalizePath(root, catalogPath)} could not be read: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + for (const [locale, entries] of nativeCatalogs) { + for (const key of NATIVE_REQUIRED_KEYS) { + if (!entries.has(key)) { + issues.push(`mobile/locales/${locale}.json missing required native key: ${key}`) + } + } + for (const key of entries.keys()) { + if (!NATIVE_REQUIRED_KEYS.includes(key)) { + issues.push(`mobile/locales/${locale}.json has unsupported native key: ${key}`) + } + } + } + const englishEntries = nativeCatalogs.get('en') + if (englishEntries) { + issues.push(...verifyNativeFallbacks(expoConfig, englishEntries)) + } + return issues +} + async function readCatalogs(root) { const localeDirectory = path.join(root, LOCALES_RELATIVE_DIR) const catalogs = new Map() @@ -259,6 +428,7 @@ function reportIssues(issues) { export async function main(root = process.cwd()) { const { catalogs, issues } = await readCatalogs(root) + issues.push(...(await verifyNativeCatalogs(root))) const englishEntries = catalogs.get('en') if (!englishEntries) { reportIssues(issues) diff --git a/config/scripts/verify-mobile-localization-catalog.test.mjs b/config/scripts/verify-mobile-localization-catalog.test.mjs index 0d5b3cd06c8..64e59ce9f05 100644 --- a/config/scripts/verify-mobile-localization-catalog.test.mjs +++ b/config/scripts/verify-mobile-localization-catalog.test.mjs @@ -7,23 +7,69 @@ import { describe, expect, it, vi } from 'vitest' import { main as verifyMobileCatalog } from './verify-mobile-localization-catalog.mjs' const LOCALES = ['en', 'es', 'ja', 'ko', 'zh'] +const NATIVE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh-Hans'] +const NATIVE_CATALOG = { + ios: { + CFBundleDisplayName: 'Orca', + NSCameraUsageDescription: 'Camera fallback', + NSLocalNetworkUsageDescription: 'Network fallback', + NSMicrophoneUsageDescription: 'Microphone fallback', + NSPhotoLibraryUsageDescription: 'Photo fallback' + }, + android: { app_name: 'Orca' } +} + +function defaultAppConfig() { + return { + expo: { + name: 'Orca', + locales: Object.fromEntries( + NATIVE_LOCALES.map((locale) => [locale, `./locales/${locale}.json`]) + ), + ios: { + infoPlist: { + NSLocalNetworkUsageDescription: 'Network fallback', + NSMicrophoneUsageDescription: 'Microphone fallback', + NSPhotoLibraryUsageDescription: 'Photo fallback' + } + }, + plugins: [ + ['expo-localization', { supportedLocales: NATIVE_LOCALES }], + [ + 'expo-camera', + { cameraPermission: 'Camera fallback', microphonePermission: 'Microphone fallback' } + ], + ['expo-image-picker', { photosPermission: 'Photo fallback' }] + ] + } + } +} function writeJson(filePath, value) { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8') } -function makeProject({ sourceText, catalogs }) { +function makeProject({ sourceText, catalogs, nativeCatalogs, appConfig }) { const root = mkdtempSync(path.join(tmpdir(), 'orca-mobile-localization-')) const appDirectory = path.join(root, 'mobile', 'app') const sourceDirectory = path.join(root, 'mobile', 'src') const localeDirectory = path.join(sourceDirectory, 'i18n', 'locales') + const nativeLocaleDirectory = path.join(root, 'mobile', 'locales') mkdirSync(appDirectory, { recursive: true }) mkdirSync(localeDirectory, { recursive: true }) + mkdirSync(nativeLocaleDirectory, { recursive: true }) writeFileSync(path.join(appDirectory, 'Example.tsx'), sourceText, 'utf8') + writeJson(path.join(root, 'mobile', 'app.json'), appConfig ?? defaultAppConfig()) for (const locale of LOCALES) { writeJson(path.join(localeDirectory, `${locale}.json`), catalogs?.[locale] ?? catalogs.en) } + for (const locale of NATIVE_LOCALES) { + writeJson( + path.join(nativeLocaleDirectory, `${locale}.json`), + nativeCatalogs?.[locale] ?? NATIVE_CATALOG + ) + } return root } @@ -120,4 +166,60 @@ describe('verify-mobile-localization-catalog', () => { 'en.json: m.label contains an encoded HTML entity' ) }) + + it('rejects empty target translations before i18next can render a blank label', async () => { + const root = makeProject({ + sourceText: "export const label = 'not rendered'\n", + catalogs: { en: { m: { label: 'Visible' } }, es: { m: { label: ' ' } } } + }) + + expect(await runFailedVerification(root)).toContain('es.json: m.label must not be empty') + }) + + it('rejects translated technical literals and commands', async () => { + const root = makeProject({ + sourceText: "export const label = 'not rendered'\n", + catalogs: { + en: { m: { command: 'Run pnpm build from orca.yaml' } }, + es: { m: { command: 'Ejecuta compilación pnpm desde Orca.yaml' } } + } + }) + + const report = await runFailedVerification(root) + expect(report).toContain('must preserve pnpm build') + expect(report).toContain('must preserve orca.yaml') + }) + + it('validates native locale paths, zh-Hans mapping, and required keys', async () => { + const appConfig = defaultAppConfig() + appConfig.expo.locales['zh-Hans'] = './locales/zh.json' + const root = makeProject({ + sourceText: "export const label = 'not rendered'\n", + catalogs: { en: { m: { label: 'Visible' } } }, + nativeCatalogs: { + es: { ios: { ...NATIVE_CATALOG.ios, NSCameraUsageDescription: undefined } } + }, + appConfig + }) + + const report = await runFailedVerification(root) + expect(report).toContain('locale zh-Hans must map to ./locales/zh-Hans.json') + expect(report).toContain( + 'mobile/locales/es.json missing required native key: ios.NSCameraUsageDescription' + ) + }) + + it('keeps app-config fallbacks aligned with the English native catalog', async () => { + const appConfig = defaultAppConfig() + appConfig.expo.plugins[1][1].cameraPermission = 'Different fallback' + const root = makeProject({ + sourceText: "export const label = 'not rendered'\n", + catalogs: { en: { m: { label: 'Visible' } } }, + appConfig + }) + + expect(await runFailedVerification(root)).toContain( + 'mobile/app.json English native fallback mismatch: ios.NSCameraUsageDescription' + ) + }) }) diff --git a/mobile/app.json b/mobile/app.json index c9823df1574..78487050445 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -26,7 +26,7 @@ "buildNumber": "1", "infoPlist": { "CFBundleAllowMixedLocalizations": true, - "NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your LAN.", + "NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.", "NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.", "NSPhotoLibraryUsageDescription": "Allow Orca to attach photos from your library to a terminal session on your paired desktop.", "NSAppTransportSecurity": { @@ -105,7 +105,7 @@ "expo-camera", { "cameraPermission": "Allow Orca to use the camera for QR code scanning.", - "microphonePermission": "Allow Orca to use your microphone for voice dictation sent to your paired desktop for transcription.", + "microphonePermission": "Allow Orca to record voice dictation and transcribe it on your paired desktop.", "recordAudioAndroid": false } ], diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index 32fc12b59b2..ea6170ccec2 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -1,4 +1,6 @@ import { colors } from '../theme/mobile-theme' +import { escapeEmbeddedHtmlCopy } from '../i18n/embedded-webview-copy' +import { t } from '../i18n/mobile-i18n' import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' export function escapeInjectedJavaScriptString(value: string): string { @@ -204,7 +206,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
- + @@ -1885,6 +1888,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}