import { readFile, readdir, stat } from 'node:fs/promises' import { join } from 'node:path' import { parsePlistSource } from './plist.ts' import type { AppPreferenceValue, AppSettingsSpecifier } from '@rnx/globals' const MAX_PLIST_BYTES = 1_048_576 const MAX_CHILD_DEPTH = 8 const MAX_PLIST_FILES = 32 const MAX_SPECIFIERS = 512 type PlistRecord = Record interface ParseBudget { files: number specifiers: number } interface CachedSettingsBundle { fingerprint: string specifiers: readonly AppSettingsSpecifier[] | null } const settingsBundleCache = new Map() function isRecord(value: unknown): value is PlistRecord { return typeof value === 'object' && value !== null && !Array.isArray(value) } function optionalString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } function requiredString(value: unknown): string | null { if (typeof value !== 'string') return null const normalized = value.trim() return normalized ? normalized : null } function finiteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } function preferenceValue(value: unknown): AppPreferenceValue | null { if (typeof value === 'string' || typeof value === 'boolean') return value return finiteNumber(value) } function keyboardType( value: unknown, ): Extract['keyboardType'] { if (typeof value !== 'string') return undefined switch (value.replaceAll(/[-_\s]/g, '').toLowerCase()) { case 'alphabet': return 'alphabet' case 'numbersandpunctuation': return 'numbers-and-punctuation' case 'numberpad': return 'number-pad' case 'url': return 'url' case 'emailaddress': return 'email-address' case 'namephonepad': return 'name-phone-pad' case 'asciicapable': return 'ascii-capable' default: return undefined } } function autocapitalization( value: unknown, ): Extract['autocapitalization'] { if (typeof value !== 'string') return undefined switch (value.replaceAll(/[-_\s]/g, '').toLowerCase()) { case 'none': return 'none' case 'sentences': return 'sentences' case 'words': return 'words' case 'allcharacters': return 'all-characters' default: return undefined } } function autocorrection(value: unknown): boolean | undefined { if (typeof value === 'boolean') return value if (typeof value !== 'string') return undefined switch (value.trim().toLowerCase()) { case 'yes': return true case 'no': return false default: return undefined } } function safeChildPlistName(value: unknown): { file: string; name: string } | null { const file = requiredString(value) if (!file || file.includes('/') || file.includes('\\') || file.includes('..')) { return null } return { file, name: file.toLowerCase().endsWith('.plist') ? file : `${file}.plist`, } } async function readableSettingsBundle(path: string): Promise { try { const root = await stat(join(path, 'Root.plist')) return root.isFile() && root.size <= MAX_PLIST_BYTES } catch { return false } } async function findSettingsBundle(projectRoot: string): Promise { const directCandidates = [ join(projectRoot, 'Settings.bundle'), join(projectRoot, 'ios', 'Settings.bundle'), ] for (const candidate of directCandidates) { if (await readableSettingsBundle(candidate)) return candidate } try { const iosRoot = join(projectRoot, 'ios') const children = await readdir(iosRoot, { withFileTypes: true }) const candidates = children .filter( (entry) => entry.isDirectory() && entry.name !== 'Pods' && entry.name !== 'build' && !entry.name.startsWith('.'), ) .map((entry) => join(iosRoot, entry.name, 'Settings.bundle')) .sort() for (const candidate of candidates) { if (await readableSettingsBundle(candidate)) return candidate } } catch {} return null } async function settingsBundleFingerprint(bundlePath: string): Promise { try { const entries = await readdir(bundlePath, { withFileTypes: true }) const names = entries .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.plist')) .map((entry) => entry.name) .sort() .slice(0, MAX_PLIST_FILES + 1) if (names.length === 0 || names.length > MAX_PLIST_FILES) return null const parts = await Promise.all( names.map(async (name) => { const metadata = await stat(join(bundlePath, name)) if (!metadata.isFile() || metadata.size > MAX_PLIST_BYTES) return null return `${name}:${metadata.size}:${metadata.mtimeMs}:${metadata.ctimeMs}` }), ) if (parts.some((part) => part === null)) return null return parts.join('|') } catch { return null } } async function parsePlistRecord(path: string): Promise { try { const metadata = await stat(path) if (!metadata.isFile() || metadata.size > MAX_PLIST_BYTES) return null const source = await readFile(path, 'utf8') const parsed = parsePlistSource(source) return isRecord(parsed) ? parsed : null } catch { return null } } function normalizeMultiValue( raw: PlistRecord, key: string, title: string, ): AppSettingsSpecifier | null { if (!Array.isArray(raw.Values) || !Array.isArray(raw.Titles)) return null const values: AppPreferenceValue[] = [] const titles: string[] = [] let optionType: 'string' | 'number' | 'boolean' | undefined const pairCount = Math.min(raw.Values.length, raw.Titles.length) for (let index = 0; index < pairCount; index++) { const value = preferenceValue(raw.Values[index]) const optionTitle = optionalString(raw.Titles[index]) if (value === null || optionTitle === undefined) continue const valueType: 'string' | 'number' | 'boolean' = typeof value === 'string' ? 'string' : typeof value === 'number' ? 'number' : 'boolean' if (optionType !== undefined && valueType !== optionType) continue optionType = valueType values.push(value) titles.push(optionTitle) } const firstValue = values[0] if (firstValue === undefined) return null const declaredDefault = preferenceValue(raw.DefaultValue) const defaultValue = declaredDefault !== null && values.includes(declaredDefault) ? declaredDefault : firstValue return { type: 'multi-value', key, title, defaultValue, currentValue: defaultValue, values, titles, } } async function normalizeSpecifier( raw: unknown, context: { bundlePath: string depth: number budget: ParseBudget stack: ReadonlySet fileCache: Map }, ): Promise { if (!isRecord(raw)) return null if (context.budget.specifiers >= MAX_SPECIFIERS) return null const type = requiredString(raw.Type) if (!type) return null context.budget.specifiers += 1 if (type === 'PSGroupSpecifier') { const title = optionalString(raw.Title) const footer = optionalString(raw.FooterText) return { type: 'group', ...(title !== undefined ? { title } : null), ...(footer !== undefined ? { footer } : null), } } const key = requiredString(raw.Key) if (type === 'PSSliderSpecifier') { if (!key) return null const minimumValue = finiteNumber(raw.MinimumValue) const maximumValue = finiteNumber(raw.MaximumValue) if (minimumValue === null || maximumValue === null || maximumValue <= minimumValue) { return null } const declaredDefault = finiteNumber(raw.DefaultValue) const defaultValue = Math.min( maximumValue, Math.max(minimumValue, declaredDefault ?? minimumValue), ) const title = optionalString(raw.Title) const minimumValueImage = optionalString(raw.MinimumValueImage) const maximumValueImage = optionalString(raw.MaximumValueImage) return { type: 'slider', key, defaultValue, currentValue: defaultValue, minimumValue, maximumValue, ...(title !== undefined ? { title } : null), ...(minimumValueImage !== undefined ? { minimumValueImage } : null), ...(maximumValueImage !== undefined ? { maximumValueImage } : null), } } if (type === 'PSChildPaneSpecifier') { const title = requiredString(raw.Title) const child = safeChildPlistName(raw.File) if (!title || !child || context.depth >= MAX_CHILD_DEPTH) return null const childSpecifiers = await parseSpecifierFile(child.name, { ...context, depth: context.depth + 1, }) if (!childSpecifiers || childSpecifiers.length === 0) return null return { type: 'child-pane', title, file: child.file, specifiers: childSpecifiers, } } if (!key) return null const title = requiredString(raw.Title) ?? key if (type === 'PSToggleSwitchSpecifier') { const defaultValue = typeof raw.DefaultValue === 'boolean' ? raw.DefaultValue : false return { type: 'toggle', key, title, defaultValue, currentValue: defaultValue, } } if (type === 'PSTextFieldSpecifier') { const defaultValue = optionalString(raw.DefaultValue) ?? '' const placeholder = optionalString(raw.Placeholder) const resolvedKeyboardType = keyboardType(raw.KeyboardType) const resolvedAutocapitalization = autocapitalization(raw.AutocapitalizationType) const resolvedAutocorrection = autocorrection(raw.AutocorrectionType) return { type: 'text', key, title, defaultValue, currentValue: defaultValue, ...(placeholder !== undefined ? { placeholder } : null), ...(typeof raw.IsSecure === 'boolean' ? { secure: raw.IsSecure } : null), ...(resolvedKeyboardType ? { keyboardType: resolvedKeyboardType } : null), ...(resolvedAutocapitalization ? { autocapitalization: resolvedAutocapitalization } : null), ...(resolvedAutocorrection !== undefined ? { autocorrection: resolvedAutocorrection } : null), } } if (type === 'PSMultiValueSpecifier' || type === 'PSRadioGroupSpecifier') { return normalizeMultiValue(raw, key, title) } if (type === 'PSTitleValueSpecifier') { const defaultValue = preferenceValue(raw.DefaultValue) ?? '' return { type: 'title-value', key, title, defaultValue, currentValue: defaultValue, } } return null } async function parseSpecifierFile( fileName: string, context: { bundlePath: string depth: number budget: ParseBudget stack: ReadonlySet fileCache: Map }, ): Promise { const cached = context.fileCache.get(fileName) if (cached !== undefined) return cached if (context.stack.has(fileName) || context.budget.files >= MAX_PLIST_FILES) { return null } context.budget.files += 1 const root = await parsePlistRecord(join(context.bundlePath, fileName)) if (!root || !Array.isArray(root.PreferenceSpecifiers)) { context.fileCache.set(fileName, null) return null } const stack = new Set(context.stack) stack.add(fileName) const specifiers: AppSettingsSpecifier[] = [] for (const raw of root.PreferenceSpecifiers) { if (context.budget.specifiers >= MAX_SPECIFIERS) break const specifier = await normalizeSpecifier(raw, { ...context, stack }) if (specifier) specifiers.push(specifier) } context.fileCache.set(fileName, specifiers) return specifiers } export async function readAppSettingsBundle( projectRoot: string, ): Promise { const bundlePath = await findSettingsBundle(projectRoot) if (!bundlePath) return null const fingerprint = await settingsBundleFingerprint(bundlePath) if (!fingerprint) return null const cached = settingsBundleCache.get(bundlePath) if (cached?.fingerprint === fingerprint) return cached.specifiers const specifiers = await parseSpecifierFile('Root.plist', { bundlePath, depth: 0, budget: { files: 0, specifiers: 0 }, stack: new Set(), fileCache: new Map(), }) const normalized = specifiers && specifiers.length > 0 ? specifiers : null settingsBundleCache.set(bundlePath, { fingerprint, specifiers: normalized }) return normalized } export function __resetAppSettingsBundleCacheForTests(): void { settingsBundleCache.clear() }