Files
openclaw/scripts/apple-app-i18n.ts
Peter Steinberger d2b382528e feat(ios): guide first launch through pairing explainer and permissions (#106129)
* feat(ios): guide first launch through pairing explainer and permissions

* fix(ios): keep first-run flow resumable and surface limited grants in onboarding

* chore(ios): regenerate native i18n after rebase

* chore(android): project refreshed native translations into strings.xml

* fix(ios): move i18n wrapper contract to shared permission row and sync catalog
2026-07-13 00:35:31 -07:00

972 lines
37 KiB
TypeScript

import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
// Keep this script independent from the translation client loaded by native-app-i18n.
// Artifact locale assertions below make drift fail instead of silently dropping a language.
export const APPLE_I18N_LOCALES = [
"zh-CN",
"zh-TW",
"pt-BR",
"de",
"es",
"ja-JP",
"ko",
"fr",
"hi",
"ar",
"it",
"tr",
"uk",
"id",
"pl",
"th",
"vi",
"nl",
"fa",
"ru",
"sv",
] as const;
const REQUIRED_LOCALES = ["en", ...APPLE_I18N_LOCALES];
const FORMAT_RE = /%(?:%|(?:\d+\$)?(?:lld|ld|[@a-z]))/giu;
const INFLECTED_COUNT_INTERPOLATION_RE = /\\\([A-Za-z_][A-Za-z0-9_]*\)/gu;
const INFLECTED_COUNT_INTERPOLATION_EXACT_RE = /^\\\([A-Za-z_][A-Za-z0-9_]*\)$/u;
const INFLECTED_COUNT_SEGMENT_RE =
/\^\[[^\]]*\\\([A-Za-z_][A-Za-z0-9_]*\)[^\]]*\]\(inflect: true\)/gu;
const INFLECTED_COUNT_MARKER = "](inflect: true)";
const IOS_CATALOG_PATH = "apps/ios/Resources/Localizable.xcstrings";
const IOS_CONTRADICTIONS_PATH = "apps/.i18n/apple-translation-contradictions.json";
const NATIVE_SOURCE_PATH = "apps/.i18n/native-source.json";
const NATIVE_TRANSLATIONS_DIR = "apps/.i18n/native";
const IOS_SOURCE_PREFIXES = [
"apps/ios/",
"apps/shared/OpenClawKit/Sources/OpenClawChatUI/",
"apps/shared/OpenClawKit/Sources/OpenClawKit/",
] as const;
const IOS_CATALOG_KINDS = new Set([
"conditional-branch",
"ui-call",
"ui-call-multiline",
"ui-localized-call",
"ui-localized-call-multiline",
"ui-modifier",
"ui-named-argument",
"ui-named-argument-multiline",
]);
const IOS_CATALOG_EXCLUSIONS = new Set([
// Product names and preview-only single-character fixtures are intentionally verbatim.
"OpenClaw",
"z",
]);
const IOS_INFO_PLIST_TARGETS = [
{
outputRoot: "apps/ios/Sources",
sourcePath: "apps/ios/Sources/Info.plist",
},
{
outputRoot: "apps/ios/WatchApp",
sourcePath: "apps/ios/WatchApp/Info.plist",
},
{
outputRoot: "apps/ios/ShareExtension",
sourcePath: "apps/ios/ShareExtension/Info.plist",
},
{
outputRoot: "apps/ios/ActivityWidget",
sourcePath: "apps/ios/ActivityWidget/Info.plist",
},
] as const;
const AMBIGUOUS_RUNTIME_INTERPOLATIONS = [
{
label: "interpolated localized resource",
pattern:
/(?:\bString\s*\(\s*localized:|\bAttributedString\s*\(\s*localized:|\bLocalizedString(?:Key|Resource)\s*\(|(?:\b[A-Za-z_]\w*)?\.localized(?:Format)?\s*\()\s*"((?:\\.|[^"\\])*)"/gu,
allowsInflection: true,
},
{
label: "interpolated multiline localized resource",
pattern:
/\b(?:String\s*\(\s*localized:|AttributedString\s*\(\s*localized:|LocalizedString(?:Key|Resource)\s*\()\s*"""([\s\S]*?)"""/gu,
allowsInflection: true,
},
{
label: "interpolated SwiftUI text literal",
pattern:
/\b(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"((?:\\.|[^"\\])*)"/gu,
allowsInflection: false,
},
{
label: "interpolated multiline SwiftUI text literal",
pattern:
/\b(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"""([\s\S]*?)"""/gu,
allowsInflection: false,
},
{
label: "interpolated multiline SwiftUI modifier literal",
pattern:
/\.(?:accessibilityLabel|accessibilityHint|alert|confirmationDialog|help|navigationTitle)\s*\(\s*"""([\s\S]*?)"""/gu,
allowsInflection: false,
},
{
label: "interpolated SwiftUI modifier literal",
pattern:
/\.(?:accessibilityLabel|accessibilityHint|alert|confirmationDialog|help|navigationTitle)\s*\(\s*"((?:\\.|[^"\\])*)"/gu,
allowsInflection: false,
},
{
label: "interpolated accessibility model literal",
pattern: /\baccessibilityLabel\s*:\s*"((?:\\.|[^"\\])*)"/gu,
allowsInflection: false,
},
] as const;
export function findAmbiguousRuntimeInterpolations(source: string): string[] {
const violations: string[] = [];
for (const { label, pattern, allowsInflection } of AMBIGUOUS_RUNTIME_INTERPOLATIONS) {
pattern.lastIndex = 0;
for (const match of source.matchAll(pattern)) {
const literal = match[1] ?? "";
if (!literal.includes("\\(") || (allowsInflection && isInflectedCountSource(literal))) {
continue;
}
violations.push(label);
break;
}
}
return violations;
}
const APPLE_LOCALE_DIRECTORIES: Record<string, string> = {
"ja-JP": "ja",
"zh-CN": "zh-Hans",
"zh-TW": "zh-Hant",
};
const LOCALIZED_WRAPPER_CONTRACTS: Record<string, readonly string[]> = {
"apps/ios/Sources/Design/OpenClawProComponents.swift": [
"enum OpenClawTextValue: ExpressibleByStringLiteral",
"struct ProSectionHeader: View {\n let title: OpenClawTextValue",
"struct OpenClawNoticeBanner: View {\n let icon: String\n let title: OpenClawTextValue\n let message: OpenClawTextValue",
"struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {\n let title: OpenClawTextValue\n let subtitle: OpenClawTextValue?",
"struct OpenClawStatusBadge: View {\n @Environment(\\.colorScheme) private var colorScheme\n let label: OpenClawTextValue",
"struct ProMetricTile: View {\n @Environment(\\.colorScheme) private var colorScheme\n let title: OpenClawTextValue",
"struct ProPanelHeader: View {\n let title: OpenClawTextValue",
"struct ProStatusRow: View {\n let icon: String\n let title: OpenClawTextValue\n let detail: OpenClawTextValue",
],
"apps/ios/Sources/Design/SettingsProTabSupport.swift": [
"struct SettingsDetailRow: View {\n let label: LocalizedStringKey\n let value: OpenClawTextValue",
"init(_ label: LocalizedStringKey, value: OpenClawTextValue)",
"self.value.text",
"struct SettingsApprovalItem: Identifiable {\n let id: String\n let icon: String\n let title: OpenClawTextValue\n let detail: OpenClawTextValue\n let priority: OpenClawTextValue",
"self.item.title.text",
"self.item.detail.text",
"self.item.priority.text",
],
"apps/ios/Sources/Design/SettingsChannelsDestination.swift": [
"Text(verbatim: self.summaryDetail)",
"Text(verbatim: self.entry.label)",
"Text(verbatim: self.entry.detailText)",
"Text(verbatim: account.displayName)",
"Text(verbatim: account.detailText)",
],
"apps/ios/Sources/Design/SettingsProTabActions.swift": [
"func detailStatusCard(\n icon: String,\n title: OpenClawTextValue,\n detail: OpenClawTextValue,\n value: OpenClawTextValue",
"func diagnosticCheckRow(\n icon: String,\n title: OpenClawTextValue,\n detail: OpenClawTextValue,\n value: OpenClawTextValue",
"pendingApproval.commandPreview.map(OpenClawTextValue.verbatim)",
'format: String(localized: "Agent: %@")',
],
"apps/ios/Sources/Design/SettingsProTabSections.swift": [
"func settingsListRow(\n icon: String,\n iconColor: Color,\n title: LocalizedStringKey",
"func aboutLinkRow(\n title: LocalizedStringKey",
"func toggleCard(title: LocalizedStringKey",
"func gatewaySecureField(\n _ placeholder: LocalizedStringKey",
"func settingsToggle(\n _ title: LocalizedStringKey",
".accessibilityLabel(Text(placeholder))",
".accessibilityLabel(Text(title))",
],
"apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift": [
'String(localized: "Secure connection is required for this host.")',
'String(localized: "Use only on a trusted private network.")',
],
"apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift": [
"private struct ExecApprovalPromptMetadataRow: View {\n let label: LocalizedStringKey",
'localized: "about ^[\\(minutes) minute](inflect: true)"',
'localized: "about ^[\\(hours) hour](inflect: true)"',
],
"apps/ios/Sources/Voice/TalkGatewayPermissionState.swift": [
'String(format: String(localized: "Missing %@"), scope)',
'String(localized: "Requesting approval")',
],
"apps/ios/Sources/Voice/TalkModeManager.swift": [
'format: String(localized: "Speech error: %@")',
'format: String(localized: "Speak failed: %@")',
'self.setStatus(String(localized: "Listening (PTT)"), phase: .listening)',
],
"apps/ios/Sources/Voice/VoiceWakeManager.swift": [
'format: String(localized: "Recognizer error: %@")',
'self.statusText = String(localized: "Triggered")',
],
"apps/ios/Sources/Design/AgentProTab+Overview.swift": [
"subtitle: .verbatim(self.agentTotalText)",
'AttributedString(localized: "^[\\(count) agent](inflect: true) total")',
"func agentMenuRow(\n icon: String,\n title: OpenClawTextValue,\n detail: OpenClawTextValue",
"func metricTile(\n icon: String,\n title: OpenClawTextValue,\n value: String,\n detail: OpenClawTextValue",
],
"apps/ios/Sources/Design/AgentProNodesDestination.swift": [
"private func nodeDetailRow(\n _ title: OpenClawTextValue,\n copyLabel: LocalizedStringKey",
"private func nodeListCard(title: OpenClawTextValue, values: [String])",
"private func detailMetric(label: OpenClawTextValue, value: String)",
"title: OpenClawTextValue,\n detail: OpenClawTextValue",
],
"apps/ios/Sources/Design/CommandCenterSupport.swift": [
"Text(verbatim: self.item.title)",
"Text(verbatim: self.item.trailing)",
"Text(verbatim: self.item.detail)",
"struct CommandEmptyStateRow: View {\n let icon: String\n let title: OpenClawTextValue\n let detail: OpenClawTextValue",
"private func actionButton(\n _ title: OpenClawTextValue",
"self.actionButton(.verbatim(category)",
],
"apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift": [
'format: String(localized: "No proposals in %@")',
],
"apps/ios/Sources/Design/IPadWorkboardScreen.swift": [
'format: String(localized: "No cards in %@")',
'format: String(localized: "Move to %@")',
],
"apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift": [
"fullRowToggle(_ title: LocalizedStringKey",
"private struct GatewayQuickSetupStatusRow: View {\n let title: LocalizedStringKey\n let value: String",
"Text(LocalizedStringKey(availability.actionTitle))",
"Text(verbatim: self.message)",
],
"apps/ios/Sources/Gateway/GatewayProblemView.swift": [
"title: .verbatim(self.problem.localizedTitle)",
"message: .verbatim(self.problem.localizedMessage)",
"Text(verbatim: primaryActionTitle)",
],
"apps/ios/Sources/Settings/PrivacyAccessSectionView.swift": [
"detail: LocalizedStringResource",
"statusLabel: LocalizedStringResource? = nil",
"actionTitle: LocalizedStringResource?",
],
"apps/ios/Sources/Permissions/DevicePermissionRow.swift": [
"title: LocalizedStringResource",
"detail: LocalizedStringResource",
"statusLabel: LocalizedStringResource?",
"actionTitle: LocalizedStringResource?",
"Text(self.title)",
"Text(actionTitle)",
],
"apps/ios/Sources/LiveActivity/LiveActivityManager.swift": [
'String(localized: "Connecting...")',
"status: .disconnected",
],
"apps/ios/WatchApp/Sources/WatchInboxView.swift": [
"enum WatchTextValue {\n case localized(LocalizedStringResource)",
"private struct WatchFaceHeader: View {\n let section: WatchTextValue\n let title: WatchTextValue\n let subtitle: WatchTextValue",
"private struct WatchHeroCard: View {\n let label: WatchTextValue\n let title: WatchTextValue\n let subtitle: WatchTextValue",
"private struct WatchStackCard: View {\n let label: WatchTextValue\n let title: WatchTextValue\n let subtitle: WatchTextValue",
"private struct WatchDecisionButton: View {\n let title: LocalizedStringKey",
'format: String(localized: "Expires in %@")',
'String(localized: "Pending review")',
'String(localized: "Review command below")',
"title: .verbatim(record.approval.commandPreview",
],
"apps/ios/WatchApp/Sources/WatchDirectNode.swift": [
'private(set) var statusText = String(\n localized: "Use iPhone Settings to enable direct connection.")',
'format: String(localized: "Direct connection failed: %@")',
],
"apps/ios/WatchApp/Sources/WatchInboxStore.swift": [
"WatchAppCommandStatus(command: command, code: .sending)",
"WatchAppCommandStatus(command: command, code: .sent)",
"WatchAppCommandStatus(command: command, code: .queued)",
],
};
const RAW_LOCALIZATION_BYPASSES: Record<string, readonly string[]> = {
"apps/ios/Sources/Design/SettingsProTabSections.swift": [
"func settingsListRow(\n icon: String,\n iconColor: Color,\n title: String",
"func aboutLinkRow(title: String",
"func toggleCard(title: String",
"func gatewayActionButton(\n title: String",
"func gatewaySecureField(_ placeholder: String",
"func settingsToggle(\n _ title: String",
".accessibilityLabel(title)",
],
"apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift": [
'helperText: "Secure connection is required for this host."',
'helperText: "Use only on a trusted private network."',
],
"apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift": [
"private struct ExecApprovalPromptMetadataRow: View {\n let label: String",
'return "under a minute"',
'return "about 1 minute"',
],
"apps/ios/Sources/Voice/TalkModeManager.swift": [
'self.statusText = "',
'streamingOwner.terminalStatus = "',
],
"apps/ios/Sources/Voice/VoiceWakeManager.swift": ['self.statusText = "'],
"apps/ios/Sources/Design/SettingsProTabSupport.swift": [
"struct SettingsDetailRow: View {\n let label: LocalizedStringKey\n let value: String",
"init(_ label: LocalizedStringKey, value: String)",
"Text(self.value)",
"Text(self.item.title)",
"Text(self.item.detail)",
"Text(self.item.priority)",
],
"apps/ios/Sources/Design/SettingsChannelsDestination.swift": [
"Text(self.summaryDetail)",
"Text(self.entry.label)",
"Text(self.entry.detailText)",
"Text(account.displayName)",
"Text(account.detailText)",
],
"apps/ios/Sources/Design/AgentProTab+Overview.swift": [
'subtitle: .verbatim("\\(self.sortedAgents.count) total")',
"func agentMenuRow(\n icon: String,\n title: String",
"func metricTile(\n icon: String,\n title: String",
],
"apps/ios/Sources/Design/AgentProNodesDestination.swift": [
"private func nodeDetailRow(_ title: String",
"private func nodeListCard(title: String",
"private func detailMetric(label: String",
"private func emptyRow(icon: String, title: String",
],
"apps/ios/Sources/Design/CommandCenterSupport.swift": [
"Text(self.item.title)",
"Text(self.item.trailing)",
"Text(self.item.detail)",
"struct CommandEmptyStateRow: View {\n let icon: String\n let title: String",
"private func actionButton(\n _ title: String",
],
"apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift": [
'"No \\(IPadSkillWorkshopScreen.proposalLaneLabel(self.status).lowercased()) proposals"',
],
"apps/ios/Sources/Design/IPadWorkboardScreen.swift": [
'"No \\(IPadWorkboardDefaults.label(for: self.status).lowercased()) cards"',
'Text("Move to \\(IPadWorkboardDefaults.label(for: status))")',
],
"apps/ios/Sources/Design/SettingsProTabActions.swift": [
"func detailStatusCard(\n icon: String,\n title: String",
"func diagnosticCheckRow(\n icon: String,\n title: String",
],
"apps/ios/WatchApp/Sources/WatchInboxView.swift": [
'parts.append("Expires in \\(expiresText)")',
'return "Expires in <1m"',
'return "Expires in \\(deltaSeconds / 60)m"',
'return "Pending review"',
'return "Review command below"',
'return "Stop speaking"',
'return "Cancel voice turn"',
'return "Start voice turn"',
],
"apps/ios/WatchApp/Sources/WatchDirectNode.swift": [
'private(set) var statusText = "',
'self.statusText = "',
],
"apps/ios/WatchApp/Sources/WatchInboxStore.swift": [
'self.appSnapshotStatusText = "',
'self.appCommandStatusText = "',
'self.execApprovals[index].statusText = "',
'self.replyStatusText = "',
],
};
const MACOS_CATALOG = {
path: "apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings",
coverage: {
"apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift": [
"Logout",
"Refresh",
"Save",
],
"apps/macos/Sources/OpenClaw/CronSettings+Rows.swift": ["Run now"],
},
} as const;
type StringUnit = {
state?: string;
value?: string;
};
type CatalogEntry = {
localizations?: Record<string, { stringUnit?: StringUnit }>;
};
type Catalog = {
sourceLanguage?: string;
strings?: Record<string, CatalogEntry>;
version?: string;
};
type NativeSourceEntry = {
id: string;
kind: string;
line: number;
path: string;
source: string;
surface: string;
};
type NativeSourceArtifact = {
entries: NativeSourceEntry[];
version: number;
};
type NativeTranslationArtifact = {
entries: Array<{ id: string; source: string; translated: string }>;
locale: string;
version: number;
};
export type AppleTranslationContradiction = {
locale: string;
source: string;
translations: string[];
};
export type AppleCatalogBuild = {
catalog: Catalog;
contradictions: AppleTranslationContradiction[];
};
function formatTokens(value: string): string[] {
return [...value.matchAll(FORMAT_RE)].map((match) => match[0]).toSorted();
}
function stringsLiteral(value: string): string {
return JSON.stringify(value);
}
function compareCodeUnits(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function serializeCatalog(catalog: Catalog): string {
return `${JSON.stringify(catalog, null, 2)}\n`;
}
function serializeContradictions(contradictions: AppleTranslationContradiction[]): string {
return `${JSON.stringify({ version: 1, contradictions }, null, 2)}\n`;
}
function decodeXml(value: string): string {
return value
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&");
}
function parseInfoPlistStrings(source: string): Array<{ key: string; source: string }> {
return [...source.matchAll(/<key>([^<]+)<\/key>\s*<string>([\s\S]*?)<\/string>/gu)]
.map((match) => ({
key: decodeXml(match[1] ?? ""),
source: decodeXml(match[2] ?? ""),
}))
.filter(
(entry) => entry.key === "CFBundleDisplayName" || entry.key.endsWith("UsageDescription"),
);
}
type InfoPlistTranslation = {
source?: string;
value: string;
};
function parseStringsFile(source: string): Map<string, InfoPlistTranslation> {
const values = new Map<string, InfoPlistTranslation>();
for (const match of source.matchAll(
/(?:^\/\* OpenClaw source: ("(?:\\.|[^"\\])*") \*\/\n)?^\s*("(?:\\.|[^"\\])*")\s*=\s*("(?:\\.|[^"\\])*");/gmu,
)) {
values.set(JSON.parse(match[2] ?? '""') as string, {
source: match[1] ? (JSON.parse(match[1]) as string) : undefined,
value: JSON.parse(match[3] ?? '""') as string,
});
}
return values;
}
export function selectInfoPlistTranslation(
source: string,
candidates: readonly string[],
existing?: InfoPlistTranslation,
): string {
const translatedCandidates = candidates.filter(
(candidate) => candidate.trim() && candidate.trim() !== source.trim(),
);
if (translatedCandidates.length > 0) {
return chooseTranslation(source, translatedCandidates);
}
return existing?.source === source && existing.value.trim() ? existing.value : source;
}
export function infoPlistTranslationCandidates(
artifact: NativeTranslationArtifact | undefined,
sourceId: string,
source: string,
): string[] {
return (
artifact?.entries
.filter((entry) => entry.id === sourceId && entry.source === source)
.map((entry) => entry.translated) ?? []
);
}
async function readOptionalFile(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}
function isIosCatalogEntry(entry: NativeSourceEntry): boolean {
return (
entry.surface === "apple" &&
IOS_SOURCE_PREFIXES.some((prefix) => entry.path.startsWith(prefix)) &&
IOS_CATALOG_KINDS.has(entry.kind) &&
(!entry.source.includes("\\(") || isInflectedCountSource(entry.source)) &&
!IOS_CATALOG_EXCLUSIONS.has(entry.source)
);
}
function isInflectedCountSource(value: string): boolean {
if (!value.includes(INFLECTED_COUNT_MARKER)) {
return false;
}
const interpolations = value.match(/\\\([^)]*\)/gu) ?? [];
const segments = [...value.matchAll(INFLECTED_COUNT_SEGMENT_RE)];
return (
segments.length > 0 &&
interpolations.length === segments.length &&
interpolations.every((interpolation) =>
INFLECTED_COUNT_INTERPOLATION_EXACT_RE.test(interpolation),
) &&
segments.every(
([segment]) => (segment.match(INFLECTED_COUNT_INTERPOLATION_RE) ?? []).length === 1,
)
);
}
function appleCatalogValue(value: string): string {
if (!isInflectedCountSource(value)) {
return value;
}
return value.replace(INFLECTED_COUNT_SEGMENT_RE, (segment) =>
segment.replace(INFLECTED_COUNT_INTERPOLATION_RE, "%lld"),
);
}
function chooseTranslation(source: string, translations: readonly string[]): string {
// Apple catalogs key by source, so duplicate native contexts must converge.
// Preserve shipped values first; otherwise choose deterministically and report every conflict.
const counts = new Map<string, number>();
for (const translation of translations) {
counts.set(translation, (counts.get(translation) ?? 0) + 1);
}
return (
[...counts].toSorted(([leftValue, leftCount], [rightValue, rightCount]) => {
const sourcePenalty = Number(leftValue === source) - Number(rightValue === source);
return sourcePenalty || rightCount - leftCount || compareCodeUnits(leftValue, rightValue);
})[0]?.[0] ?? source
);
}
export function buildIosCatalog(
existingCatalog: Catalog,
nativeSource: NativeSourceArtifact,
translations: readonly NativeTranslationArtifact[],
): AppleCatalogBuild {
const iosEntries = nativeSource.entries.filter(isIosCatalogEntry);
const catalogEntries = iosEntries.map(
(entry) => [entry, appleCatalogValue(entry.source)] as const,
);
const sources = [...new Set(catalogEntries.map(([, source]) => source))].toSorted(
compareCodeUnits,
);
const appleIdsBySource = new Map<string, Set<string>>();
for (const [entry, source] of catalogEntries) {
const ids = appleIdsBySource.get(source) ?? new Set<string>();
ids.add(entry.id);
appleIdsBySource.set(source, ids);
}
const existingStrings = existingCatalog.strings ?? {};
const translationsByLocale = new Map(
translations.map((artifact) => {
const bySource = new Map<string, string[]>();
for (const entry of artifact.entries) {
const source = appleCatalogValue(entry.source);
if (!sources.includes(source)) {
continue;
}
const appleIds = appleIdsBySource.get(source);
if (appleIds && !appleIds.has(entry.id)) {
continue;
}
const values = bySource.get(source) ?? [];
values.push(appleCatalogValue(entry.translated));
bySource.set(source, values);
}
return [artifact.locale, bySource] as const;
}),
);
const contradictions: AppleTranslationContradiction[] = [];
const strings: Record<string, CatalogEntry> = {};
for (const source of sources) {
const existing = existingStrings[source];
const localizations: NonNullable<CatalogEntry["localizations"]> = {};
for (const locale of REQUIRED_LOCALES) {
const candidates = translationsByLocale.get(locale)?.get(source) ?? [];
const distinct = [...new Set(candidates)].toSorted(compareCodeUnits);
if (distinct.length > 1) {
contradictions.push({ locale, source, translations: distinct });
}
const existingUnit = existing?.localizations?.[locale]?.stringUnit;
if (existingUnit?.value && existingUnit.state === "translated") {
localizations[locale] = { stringUnit: { ...existingUnit } };
continue;
}
if (locale === "en") {
localizations.en = {
stringUnit: {
state: "translated",
value: source,
},
};
continue;
}
const value = chooseTranslation(source, candidates);
localizations[locale] = {
stringUnit: {
state: value === source ? "new" : "translated",
value,
},
};
}
strings[source] = { localizations };
}
return {
catalog: {
sourceLanguage: "en",
strings,
version: "1.0",
},
contradictions: contradictions.toSorted(
(left, right) =>
compareCodeUnits(left.source, right.source) || compareCodeUnits(left.locale, right.locale),
),
};
}
async function listSwiftFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map(async (entry) => {
if (
entry.isDirectory() &&
/^(?:\.build|build|DerivedData|Previews?|Tests?|UITests?)$/u.test(entry.name)
) {
return [];
}
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listSwiftFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".swift") ? [entryPath] : [];
}),
);
return nested.flat();
}
async function validateRuntimeInterpolationPaths(): Promise<void> {
const roots = IOS_SOURCE_PREFIXES.map((prefix) => path.join(ROOT, prefix));
const files = (await Promise.all(roots.map(listSwiftFiles))).flat();
const violations: string[] = [];
for (const file of files) {
const source = await readFile(file, "utf8");
for (const label of findAmbiguousRuntimeInterpolations(source)) {
violations.push(`${path.relative(ROOT, file)}: ${label}`);
}
}
if (violations.length) {
throw new Error(
`Apple i18n runtime interpolation bypasses generated catalog coverage:\n${violations.join("\n")}`,
);
}
}
async function readNativeTranslations(): Promise<NativeTranslationArtifact[]> {
const expectedFiles = APPLE_I18N_LOCALES.map((locale) => `${locale}.json`).toSorted();
const actualFiles = (
await readdir(path.join(ROOT, NATIVE_TRANSLATIONS_DIR), {
withFileTypes: true,
})
)
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => entry.name)
.toSorted();
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error(
`Apple/native locale parity drift: expected ${JSON.stringify(expectedFiles)}, got ${JSON.stringify(actualFiles)}`,
);
}
return Promise.all(
APPLE_I18N_LOCALES.map(async (locale) => {
const artifact = JSON.parse(
await readFile(path.join(ROOT, NATIVE_TRANSLATIONS_DIR, `${locale}.json`), "utf8"),
) as NativeTranslationArtifact;
if (artifact.locale !== locale) {
throw new Error(
`native Apple translation artifact ${locale} declares locale ${artifact.locale}`,
);
}
return artifact;
}),
);
}
async function readIosCatalogBuild(): Promise<AppleCatalogBuild> {
const existingCatalog = JSON.parse(
await readFile(path.join(ROOT, IOS_CATALOG_PATH), "utf8"),
) as Catalog;
const nativeSource = JSON.parse(
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
) as NativeSourceArtifact;
const translations = await readNativeTranslations();
return buildIosCatalog(existingCatalog, nativeSource, translations);
}
function validateCatalog(pathName: string, catalog: Catalog): number {
if (catalog.sourceLanguage !== "en" || catalog.version !== "1.0" || !catalog.strings) {
throw new Error(`invalid Apple string catalog: ${pathName}`);
}
let checked = 0;
for (const [key, entry] of Object.entries(catalog.strings)) {
const sourceTokens = formatTokens(key);
for (const locale of REQUIRED_LOCALES) {
const unit = entry.localizations?.[locale]?.stringUnit;
const value = unit?.value?.trim();
if (!value || (locale === "en" && unit?.state !== "translated")) {
throw new Error(
`Apple catalog ${pathName} is missing ${locale} for ${JSON.stringify(key)}`,
);
}
if (locale !== "en" && unit?.state !== "translated" && unit?.state !== "new") {
throw new Error(
`Apple catalog ${pathName} has untranslated ${locale} copy for ${JSON.stringify(key)}`,
);
}
if (formatTokens(value).join("\u0000") !== sourceTokens.join("\u0000")) {
throw new Error(
`Apple catalog ${pathName} has placeholder drift in ${locale} for ${JSON.stringify(key)}`,
);
}
}
checked += 1;
}
return checked;
}
async function syncIosInfoPlist(write: boolean): Promise<number> {
const translations = await readNativeTranslations();
const nativeSource = JSON.parse(
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
) as NativeSourceArtifact;
const sourceIds = new Map(
nativeSource.entries
.filter((entry) => entry.kind === "plist-string")
.map((entry) => [[entry.path, entry.source].join("\u0000"), entry.id]),
);
let checked = 0;
for (const target of IOS_INFO_PLIST_TARGETS) {
const sourceEntries = parseInfoPlistStrings(
await readFile(path.join(ROOT, target.sourcePath), "utf8"),
);
for (const locale of APPLE_I18N_LOCALES) {
const localeDir = APPLE_LOCALE_DIRECTORIES[locale] ?? locale;
const outputPath = path.join(
ROOT,
target.outputRoot,
`${localeDir}.lproj`,
"InfoPlist.strings",
);
const existingSource = await readOptionalFile(outputPath);
const existing = parseStringsFile(existingSource ?? "");
const artifact = translations.find((candidate) => candidate.locale === locale);
const lines = sourceEntries.map(({ key, source }) => {
if (key === "CFBundleDisplayName") {
return `${stringsLiteral(key)} = ${stringsLiteral(source)};`;
}
const sourceId = sourceIds.get([target.sourcePath, source].join("\u0000"));
if (!sourceId) {
throw new Error(`missing native InfoPlist source id for ${target.sourcePath}:${key}`);
}
const candidates = infoPlistTranslationCandidates(artifact, sourceId, source);
const value = selectInfoPlistTranslation(source, candidates, existing.get(key));
return [
`/* OpenClaw source: ${stringsLiteral(source)} */`,
`${stringsLiteral(key)} = ${stringsLiteral(value)};`,
].join("\n");
});
const expected = `${lines.join("\n")}\n`;
if (existingSource !== expected) {
if (!write) {
throw new Error(
`Apple InfoPlist localization ${path.relative(ROOT, outputPath)} is stale; run apple-app-i18n.ts sync-ios --write`,
);
}
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, expected, "utf8");
}
checked += 1;
}
}
return checked;
}
export async function syncIosCatalog(write: boolean): Promise<AppleCatalogBuild> {
const build = await readIosCatalogBuild();
const catalogPath = path.join(ROOT, IOS_CATALOG_PATH);
const expected = serializeCatalog(build.catalog);
const actual = await readFile(catalogPath, "utf8");
if (actual !== expected) {
if (!write) {
throw new Error(
`Apple catalog ${IOS_CATALOG_PATH} is stale; run apple-app-i18n.ts sync-ios --write`,
);
}
await writeFile(catalogPath, expected, "utf8");
}
const contradictionsPath = path.join(ROOT, IOS_CONTRADICTIONS_PATH);
const expectedContradictions = serializeContradictions(build.contradictions);
const actualContradictions = await readOptionalFile(contradictionsPath);
if (actualContradictions !== expectedContradictions) {
if (!write) {
throw new Error(
`Apple contradiction report ${IOS_CONTRADICTIONS_PATH} is stale; run apple-app-i18n.ts sync-ios --write`,
);
}
await writeFile(contradictionsPath, expectedContradictions, "utf8");
}
return build;
}
export async function checkAppleAppI18n() {
await validateRuntimeInterpolationPaths();
for (const [sourcePath, contracts] of Object.entries(LOCALIZED_WRAPPER_CONTRACTS)) {
const source = await readFile(path.join(ROOT, sourcePath), "utf8");
const missing = contracts.filter((contract) => !source.includes(contract));
if (missing.length) {
throw new Error(
`Apple i18n wrapper ${sourcePath} bypasses localized string lookup: ${missing.join(", ")}`,
);
}
}
for (const [sourcePath, bypasses] of Object.entries(RAW_LOCALIZATION_BYPASSES)) {
const source = await readFile(path.join(ROOT, sourcePath), "utf8");
const present = bypasses.filter((bypass) => source.includes(bypass));
if (present.length) {
throw new Error(
`Apple i18n runtime path ${sourcePath} contains raw app-owned strings: ${present.join(", ")}`,
);
}
}
const iosBuild = await syncIosCatalog(false);
const iosKeys = validateCatalog(IOS_CATALOG_PATH, iosBuild.catalog);
const infoPlistFiles = await syncIosInfoPlist(false);
const macosCatalog = JSON.parse(
await readFile(path.join(ROOT, MACOS_CATALOG.path), "utf8"),
) as Catalog;
if (!macosCatalog.strings) {
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG.path}`);
}
const expectedMacosKeys: Set<string> = new Set(Object.values(MACOS_CATALOG.coverage).flat());
const actualMacosKeys = new Set(Object.keys(macosCatalog.strings));
const missingMacosKeys = [...expectedMacosKeys].filter((key) => !actualMacosKeys.has(key));
const extraMacosKeys = [...actualMacosKeys].filter((key) => !expectedMacosKeys.has(key));
if (missingMacosKeys.length || extraMacosKeys.length) {
throw new Error(
[
`Apple catalog ${MACOS_CATALOG.path} does not match its phased source coverage.`,
`missing=${missingMacosKeys.join(",") || "none"}`,
`extra=${extraMacosKeys.join(",") || "none"}`,
].join("\n"),
);
}
for (const [sourcePath, keys] of Object.entries(MACOS_CATALOG.coverage)) {
const source = await readFile(path.join(ROOT, sourcePath), "utf8");
const absent = keys.filter((key) => !source.includes(key));
if (absent.length) {
throw new Error(`Apple i18n coverage ${sourcePath} no longer contains: ${absent.join(", ")}`);
}
}
const macosKeys = validateCatalog(MACOS_CATALOG.path, macosCatalog);
process.stdout.write(
[
`apple-app-i18n: iosKeys=${iosKeys}`,
`macosKeys=${macosKeys}`,
`infoPlistFiles=${infoPlistFiles}`,
`translationContradictions=${iosBuild.contradictions.length}`,
`locales=${APPLE_I18N_LOCALES.join(",")}`,
"\n",
].join(" "),
);
}
export async function compileMacosLocalizations(outputDir: string) {
await checkAppleAppI18n();
const catalog = JSON.parse(
await readFile(path.join(ROOT, MACOS_CATALOG.path), "utf8"),
) as Catalog;
if (!catalog.strings) {
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG.path}`);
}
for (const locale of REQUIRED_LOCALES) {
const localeDir = APPLE_LOCALE_DIRECTORIES[locale] ?? locale;
const lprojDir = path.join(outputDir, `${localeDir}.lproj`);
const lines = Object.entries(catalog.strings)
.toSorted(([left], [right]) => compareCodeUnits(left, right))
.map(([key, entry]) => {
const value = entry.localizations?.[locale]?.stringUnit?.value;
if (!value) {
throw new Error(
`Apple catalog ${MACOS_CATALOG.path} is missing ${locale} for ${JSON.stringify(key)}`,
);
}
return `${stringsLiteral(key)} = ${stringsLiteral(value)};`;
});
await mkdir(lprojDir, { recursive: true });
await writeFile(path.join(lprojDir, "Localizable.strings"), `${lines.join("\n")}\n`, "utf8");
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const [command, flag, value] = process.argv.slice(2);
if (command === "check") {
await checkAppleAppI18n();
} else if (command === "sync-ios" && flag === "--write") {
const build = await syncIosCatalog(true);
const infoPlistFiles = await syncIosInfoPlist(true);
process.stdout.write(
`apple-app-i18n: synced iOS catalog and ${infoPlistFiles} InfoPlist files; contradictions=${build.contradictions.length}\n`,
);
} else if (command === "compile-macos" && flag === "--output" && value) {
await compileMacosLocalizations(path.resolve(value));
} else {
throw new Error(
"usage: node --import tsx scripts/apple-app-i18n.ts check|sync-ios --write|compile-macos --output <dir>",
);
}
}