import { createHash } from 'node:crypto' import fs from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import { expoFontPathsFromPlugins, metroFontSpecsFromPaths, type ConfigPluginFontSpec, } from 'sootsim-engine/engine/config-plugin-fonts' import { resolveListeningPid, resolveProcessCwd } from '../scripts/dev-server-scanner' import { parsePlistSource } from '../src/plist.ts' import { resolveAppProject } from './app-project' const require = createRequire(import.meta.url) // the filesystem scan and the engine's metro-manifest scan share the same spec // shape and the same path → spec logic (config-plugin-fonts.ts). export type DiscoveredAppFontSpec = ConfigPluginFontSpec type ExpoConfigGetter = (dir: string, opts: Record) => { exp?: unknown } function localOriginOf(value: string): string | null { try { const url = new URL(value) const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase() if ( host === 'localhost' || host.endsWith('.localhost') || host === '0.0.0.0' || host === '::1' || /^127(?:\.\d{1,3}){3}$/.test(host) ) { return url.origin } } catch {} return null } export function discoverNativeLinkedAppFonts(opts: { bundleUrl: string projectDir: string platform?: string }): DiscoveredAppFontSpec[] { const metroOrigin = localOriginOf(opts.bundleUrl) if (!metroOrigin) return [] const project = resolveAppProject(opts.projectDir) const selectedAppDir = project?.appDir ?? null if (!selectedAppDir) return [] const platform = (opts.platform || process.env.CONTRAST_PLATFORM || 'ios').toLowerCase() let getConfig: ExpoConfigGetter | null = null // resolve from the exact app root advertised by the development server. if (project) { try { const modulePath = require.resolve('@expo/config', { paths: [selectedAppDir], }) const mod = require(modulePath) as { getConfig?: unknown } if (typeof mod.getConfig === 'function') { getConfig = mod.getConfig as ExpoConfigGetter } } catch {} } let plugins: unknown = [] if (getConfig) { try { const result = getConfig(selectedAppDir, { skipSDKVersionRequirement: true }) if (result?.exp && typeof result.exp === 'object') { const resolvedPlugins = (result.exp as Record).plugins if (Array.isArray(resolvedPlugins)) { plugins = resolvedPlugins } } } catch {} } const fontPaths = expoFontPathsFromPlugins( plugins, platform === 'android' ? 'android' : 'ios', ).slice(0, 256) // the walk is bounded by WORK, not by a clock: at most 64 readdirs, at most 64 // queued dirs, 4 levels deep, 256 paths. those ceilings are what keep a stray // node_modules from being crawled, and no machine can blow through them. a // wall-clock budget on top of that only makes the RESULT depend on how busy // the box is, and silently — a user on a loaded machine would get fewer fonts // and no error at all. let scannedFontDirectories = 0 const collectFontFiles = (root: string, metroPathPrefix: string): void => { const pending: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }] let nextIndex = 0 while ( nextIndex < pending.length && scannedFontDirectories < 64 && fontPaths.length < 256 ) { const current = pending[nextIndex++] if (!current) continue scannedFontDirectories++ let entries: fs.Dirent[] try { entries = fs.readdirSync(current.dir, { withFileTypes: true }) } catch { continue } for (const entry of entries) { if (entry.isSymbolicLink()) continue const fullPath = path.join(current.dir, entry.name) if (entry.isDirectory()) { if (current.depth < 4 && pending.length < 64 && !entry.name.startsWith('.')) { pending.push({ dir: fullPath, depth: current.depth + 1 }) } continue } if (!entry.isFile() || !/\.(ttf|otf|woff|woff2)$/i.test(entry.name)) continue const relative = path.relative(root, fullPath).split(path.sep).join('/') fontPaths.push(path.posix.join(metroPathPrefix, relative)) if (fontPaths.length >= 256) break } } } // @react-native-vector-icons links fonts from its configured local font // directory and each installed icon package into the native app. those files // never enter the metro module graph, so rnx must stage them through the // same native-linked font wire as expo-font config-plugin assets. Font Awesome // Pro installs its licensed faces under the local directory rather than // publishing them in the npm package. try { const packageJson: object = JSON.parse( fs.readFileSync(path.join(selectedAppDir, 'package.json'), 'utf8'), ) const dependencyNames = new Set() for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) { const dependencies = Reflect.get(packageJson, field) if (!dependencies || typeof dependencies !== 'object') continue for (const name of Object.keys(dependencies)) dependencyNames.add(name) } const vectorIconDependencies = [...dependencyNames].filter( (name) => name === 'react-native-vector-icons' || name.startsWith('@react-native-vector-icons/'), ) if (vectorIconDependencies.length > 0) { const config = Reflect.get(packageJson, 'reactNativeVectorIcons') const configuredFontDir = config && typeof config === 'object' ? Reflect.get(config, 'fontDir') : null const configuredDir = typeof configuredFontDir === 'string' ? configuredFontDir : 'rnvi-fonts' const root = path.resolve(selectedAppDir, configuredDir) const relativeRoot = path.relative(selectedAppDir, root) if (!relativeRoot.startsWith('..') && !path.isAbsolute(relativeRoot)) { collectFontFiles(root, relativeRoot.split(path.sep).join('/')) } const vectorIconRequire = createRequire(path.join(selectedAppDir, 'package.json')) for (const dependencyName of vectorIconDependencies) { try { const entryPath = vectorIconRequire.resolve(dependencyName) const dependencyPath = dependencyName.split('/').join(path.sep) const marker = `${path.sep}node_modules${path.sep}${dependencyPath}${path.sep}` const markerIndex = entryPath.lastIndexOf(marker) if (markerIndex === -1) continue const packageRoot = entryPath.slice(0, markerIndex + marker.length - 1) const fontsDir = dependencyName === 'react-native-vector-icons' ? 'Fonts' : 'fonts' collectFontFiles( path.join(packageRoot, fontsDir), path.posix.join('node_modules', dependencyName, fontsDir), ) } catch {} } } } catch {} // react-native.config.js `assets` is the RN CLI's native asset-linking // source: `npx react-native-asset` copies every font in those directories // into the app bundle and Info.plist UIAppFonts, and the JS never requires // the files, so they enter no module graph. apps like mattermost register // icon sets (createIconSetFromFontello) purely by family name against a // font linked this way. try { const configPath = path.join(selectedAppDir, 'react-native.config.js') if (fs.statSync(configPath).isFile()) { const appConfigRequire = createRequire(path.join(selectedAppDir, 'package.json')) const rnConfig: unknown = appConfigRequire(configPath) const assets = rnConfig && typeof rnConfig === 'object' ? Reflect.get(rnConfig, 'assets') : null if (Array.isArray(assets)) { for (const assetDir of assets) { if (typeof assetDir !== 'string') continue const root = path.resolve(selectedAppDir, assetDir) const relativeRoot = path.relative(selectedAppDir, root) if (relativeRoot.startsWith('..') || path.isAbsolute(relativeRoot)) continue collectFontFiles(root, relativeRoot.split(path.sep).join('/')) } } } } catch {} // bare iOS projects can embed fonts directly in an Xcode target without a // react-native.config.js assets declaration. UIAppFonts is the native // contract, so stage those referenced files through Metro's asset endpoint. if (platform === 'ios') { const iosDir = path.join(selectedAppDir, 'ios') try { const targetDirs = fs .readdirSync(iosDir, { withFileTypes: true }) .filter( (entry) => entry.isDirectory() && !entry.isSymbolicLink() && entry.name !== 'Pods' && entry.name !== 'build' && !entry.name.startsWith('.'), ) .slice(0, 64) const plistPaths = [path.join(iosDir, 'Info.plist')] for (const entry of targetDirs) { plistPaths.push(path.join(iosDir, entry.name, 'Info.plist')) } for (const plistPath of plistPaths) { let parsed: unknown try { parsed = parsePlistSource(fs.readFileSync(plistPath, 'utf8')) } catch { continue } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue const declaredFonts = Reflect.get(parsed, 'UIAppFonts') if (!Array.isArray(declaredFonts)) continue for (const declaredFont of declaredFonts) { if (typeof declaredFont !== 'string') continue const candidates = [ path.resolve(path.dirname(plistPath), declaredFont), path.resolve(iosDir, declaredFont), ] const fontPath = candidates.find((candidate) => { const relative = path.relative(selectedAppDir, candidate) if (relative.startsWith('..') || path.isAbsolute(relative)) return false try { return fs.statSync(candidate).isFile() } catch { return false } }) if (!fontPath) continue fontPaths.push( path.relative(selectedAppDir, fontPath).split(path.sep).join('/'), ) } } } catch {} } return metroFontSpecsFromPaths(fontPaths, metroOrigin) } // resolve a metro dev server's project root. expo serves a manifest at the // server origin carrying `extra.expoClient._internal.projectRoot` — the absolute // dir of the app being served. the local `rnx open ` path runs from the // CLI's cwd, which almost never owns the bundle being opened (e.g. // `rnx open 8081` run from ~/soot against a 3pc metro), so scanning cwd for // font declarations finds nothing. // // a bare react-native packager serves no such manifest, so the same resolver // asks the OS for the listening process's cwd. that is still the development // server's project identity; the invoking CLI's cwd is never consulted. export async function resolveMetroProjectRoot( bundleUrl: string, opts?: { timeoutMs?: number }, ): Promise { const origin = localOriginOf(bundleUrl) if (!origin) return null const controller = new AbortController() const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? 1500) let servedManifest = false try { const res = await fetch(`${origin}/`, { headers: { 'expo-platform': 'ios', Accept: 'application/json' }, signal: controller.signal, }) if (res.ok) { const manifest = (await res.json()) as { extra?: { expoClient?: { _internal?: { projectRoot?: unknown } } } } servedManifest = true const root = manifest?.extra?.expoClient?._internal?.projectRoot if (typeof root === 'string' && root.length > 0) return root } } catch { } finally { clearTimeout(timer) } // a server that answered with a manifest has already told us everything it // knows. if it did not name a project root there is none to learn, and asking // the OS about the listening process would answer with whatever directory // that process happens to sit in — which is the CLI's own cwd trap this // function exists to avoid. only a server that served no manifest at all gets // the process lookup. if (servedManifest) return null const port = Number(new URL(origin).port) if (!Number.isFinite(port) || port <= 0) return null const pid = await resolveListeningPid(port) return pid === null ? null : resolveProcessCwd(pid) } export function encodeAppFontsWire(specs: DiscoveredAppFontSpec[]): string { return specs .map((spec) => `${encodeURIComponent(spec.url)}::${encodeURIComponent(spec.family)}`) .join(',') } export function parseAppFontUrls(wire: string): string[] { const urls: string[] = [] for (const entry of wire.split(',')) { const trimmed = entry.trim() if (!trimmed) continue const sep = trimmed.indexOf('::') const urlPart = sep === -1 ? trimmed : trimmed.slice(0, sep) try { urls.push(decodeURIComponent(urlPart)) } catch { urls.push(urlPart) } } return urls.filter(Boolean) } // content type for a config-plugin font url, by extension. config fonts only // ever ship as ttf/otf/woff(2); fall back to octet-stream for anything else // so R2 still serves bytes the engine can register. export function fontContentType(url: string): string { const ext = (url.split('?')[0].split('.').pop() || '').toLowerCase() if (ext === 'ttf') return 'font/ttf' if (ext === 'otf') return 'font/otf' if (ext === 'woff') return 'font/woff' if (ext === 'woff2') return 'font/woff2' return 'application/octet-stream' } export type FetchedAppFont = { url: string // sha256(url) — the share-store key both the build and preview upload paths // use, and what /api/preview/share/fetch?url= resolves against. urlhash: string contentType: string bytes: Uint8Array } // fetch every config-plugin font declared on an engine `?appFonts=` wire. // these typefaces live in the native binary, never in the metro bundle's // __packager_asset graph, so neither asset capture nor recorded fetches see // them — both the preview (`rnx record upload`) and build (`rnx app-fonts // stage`) paths backfill by re-fetching the declared urls off the still-live // metro. one implementation so both pipelines store identical bytes + keys. export async function fetchAppFontFiles( wire: string, hooks?: { onStaged?: (info: { url: string; byteLength: number }) => void onError?: (url: string, err: unknown) => void }, ): Promise { const out: FetchedAppFont[] = [] const seen = new Set() for (const url of parseAppFontUrls(wire)) { const urlhash = createHash('sha256').update(url).digest('hex') if (seen.has(urlhash)) continue seen.add(urlhash) try { const res = await fetch(url) if (!res.ok) { hooks?.onError?.(url, new Error(`${res.status} ${res.statusText}`)) continue } const bytes = new Uint8Array(await res.arrayBuffer()) out.push({ url, urlhash, contentType: fontContentType(url), bytes }) hooks?.onStaged?.({ url, byteLength: bytes.byteLength }) } catch (err) { hooks?.onError?.(url, err) } } return out }