import { createHash } from 'node:crypto' import fs from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import { configPluginSplashFromExpoConfig, type ConfigPluginSplashSpec, } from 'sootsim-engine/engine/config-plugin-splash' import { resolveAppProject } from './app-project' const require = createRequire(import.meta.url) // the filesystem scan and the engine's metro-manifest scan share the same // parser (config-plugin-splash.ts) and produce the same resolved spec. export type DiscoveredAppSplashSpec = ConfigPluginSplashSpec 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 } // discover the app's configured splash by evaluating its expo config on the // filesystem (mirrors discoverNativeLinkedAppFonts exactly). returns null when // no splash is configured, no app config is found, or the bundle isn't local. export function discoverConfigPluginAppSplash(opts: { bundleUrl: string projectDir: string platform?: string }): DiscoveredAppSplashSpec | null { const metroOrigin = localOriginOf(opts.bundleUrl) if (!metroOrigin) return null const project = resolveAppProject(opts.projectDir) const selectedAppDir = project?.appDir ?? null if (!selectedAppDir) return null const platform = (opts.platform || process.env.CONTRAST_PLATFORM || 'ios').toLowerCase() let getConfig: ExpoConfigGetter | null = null 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 {} if (!getConfig) return null let exp: unknown try { const result = getConfig(selectedAppDir, { skipSDKVersionRequirement: true }) if (result?.exp && typeof result.exp === 'object') exp = result.exp } catch {} if (!exp || typeof exp !== 'object') return null return configPluginSplashFromExpoConfig( exp, platform === 'android' ? 'android' : 'ios', metroOrigin, ) } // encode a resolved splash spec into the `?appSplash=` engine-URL wire value // (URL-safe JSON). the engine decodes it with parseAppSplashSpec. export function encodeAppSplashWire(spec: DiscoveredAppSplashSpec): string { return encodeURIComponent(JSON.stringify(spec)) } // parse a stamped `?appSplash=` wire value back into a spec (or null). used by // the preview upload path to learn the splash image url it must stage. export function parseAppSplashWire(wire: string): DiscoveredAppSplashSpec | null { if (!wire) return null try { const decoded = JSON.parse(decodeURIComponent(wire)) as unknown if (!decoded || typeof decoded !== 'object') return null const spec = decoded as Record if ( spec.source !== 'expo-splash-screen' && spec.source !== 'expo-legacy' && spec.source !== 'react-native-bootsplash' ) return null if (typeof spec.backgroundColor !== 'string') return null return decoded as DiscoveredAppSplashSpec } catch { return null } } // every distinct image url a splash spec references (base + dark variant). used // by the preview upload path so both the light and dark splash images get // staged as share assets. export function splashImageUrls(spec: DiscoveredAppSplashSpec): string[] { const urls = new Set() if (spec.imageUrl) urls.add(spec.imageUrl) if (spec.dark?.imageUrl) urls.add(spec.dark.imageUrl) return [...urls] } // content type for a splash image url, by extension. config splash images ship // as png/jpg/webp/gif/svg; fall back to octet-stream so the share store still // serves bytes the engine's can load. export function splashImageContentType(url: string): string { const ext = (url.split('?')[0].split('.').pop() || '').toLowerCase() if (ext === 'png') return 'image/png' if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg' if (ext === 'webp') return 'image/webp' if (ext === 'gif') return 'image/gif' if (ext === 'svg') return 'image/svg+xml' return 'application/octet-stream' } export type FetchedAppSplashImage = { url: string // sha256(url) — the share-store key, matching the font/asset convention. urlhash: string contentType: string bytes: Uint8Array } // fetch every splash image declared on an `?appSplash=` wire. like the config- // plugin fonts, these images live in the native binary / launch storyboard, // never in the metro bundle graph, so the preview upload path backfills them by // re-fetching the declared urls off the still-live metro — the same staging // pattern fetchAppFontFiles uses, keyed by sha256(url). export async function fetchAppSplashImages( wire: string, hooks?: { onStaged?: (info: { url: string; byteLength: number }) => void onError?: (url: string, err: unknown) => void }, ): Promise { const spec = parseAppSplashWire(wire) if (!spec) return [] const out: FetchedAppSplashImage[] = [] const seen = new Set() for (const url of splashImageUrls(spec)) { 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: splashImageContentType(url), bytes }) hooks?.onStaged?.({ url, byteLength: bytes.byteLength }) } catch (err) { hooks?.onError?.(url, err) } } return out }