import type {ExpoConfig} from '@expo/config-types'; import { ConfigPlugin, WarningAggregator, withDangerousMod, } from 'expo/config-plugins'; import * as fs from 'fs'; import * as path from 'path'; import {logOnce} from './logOnce'; const GENERATED_MARKER = '# @generated by expo-iap'; const GENERATED_JS_MARKER = '// @generated by expo-iap'; const DEFAULT_ICON_FILE = 'icon.png'; const DEFAULT_BUILD_TYPE = 'Release'; const DEFAULT_RUNTIME_MODULE = '/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0'; // Vega SDK 0.24 requires both the OS module and an [os.version] block. const VEGA_OS_MODULE = '/com.amazon.vega.os@IVega_1_2'; const VEGA_OS_VERSION = '1.2'; export type VegaProjectOptions = { /** * Vega package id. Defaults to android.package, then ios.bundleIdentifier. */ packageId?: string; /** * User-visible Vega app title. Defaults to Expo config name. */ title?: string; /** * Main interactive component id. Defaults to `${packageId}.main`. */ componentId?: string; /** * Kepler app name used by React Native for Vega tooling. */ appName?: string; /** * Icon path copied to assets/image for manifest.toml. Defaults to config.icon. */ icon?: string; /** * Whether to update package.json with Vega dependencies, scripts, and kepler metadata. */ syncPackageJson?: boolean; /** * Whether to generate the Vega target. `true` forces it on, `false` forces * it off, and an unset value auto-detects from the project: a root * manifest.toml means Vega, unless Fire OS is declared. */ enabled?: boolean; }; export type VegaProjectSettings = { packageId: string; title: string; version: string; iconFileName: string; componentId: string; appName: string; buildType: typeof DEFAULT_BUILD_TYPE; }; const escapeTomlString = (value: string): string => JSON.stringify(value); const sanitizePackageSegment = (segment: string): string => { const cleaned = segment.replace(/[^A-Za-z0-9_]/g, '_'); if (/^[A-Za-z]/.test(cleaned)) return cleaned; return `app_${cleaned || 'app'}`; }; export const normalizeVegaPackageId = (value: string): string => { const segments = value .trim() .replace(/[^A-Za-z0-9._]/g, '.') .split('.') .filter(Boolean) .map(sanitizePackageSegment); if (segments.length >= 2) { return segments.join('.'); } return ['dev', 'openiap', ...segments].join('.'); }; const sanitizeVegaAppName = (value: string): string => { const words = value .split(/[^A-Za-z0-9]+/) .filter(Boolean) .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`); return words.join('') || 'OpenIapVega'; }; const resolveIconFileName = (icon?: string): string => { if (!icon) return DEFAULT_ICON_FILE; const parsed = path.parse(icon); return `${parsed.name || 'icon'}${parsed.ext || '.png'}`; }; export const resolveVegaProjectSettings = ( config: ExpoConfig, options?: VegaProjectOptions, ): VegaProjectSettings => { const packageId = normalizeVegaPackageId( options?.packageId ?? config.android?.package ?? config.ios?.bundleIdentifier ?? `dev.openiap.${config.slug}`, ); const title = options?.title ?? config.name ?? config.slug; return { packageId, title, version: config.version ?? '1.0.0', iconFileName: resolveIconFileName(options?.icon ?? config.icon), componentId: options?.componentId ?? `${packageId}.main`, appName: options?.appName ?? sanitizeVegaAppName(title), buildType: DEFAULT_BUILD_TYPE, }; }; export const createVegaManifest = ( settings: VegaProjectSettings, ): string => `${GENERATED_MARKER} schema-version = 1 [package] id = ${escapeTomlString(settings.packageId)} title = ${escapeTomlString(settings.title)} version = ${escapeTomlString(settings.version)} icon = ${escapeTomlString(`@image/${settings.iconFileName}`)} [components] [[components.interactive]] id = ${escapeTomlString(settings.componentId)} runtime-module = ${escapeTomlString(DEFAULT_RUNTIME_MODULE)} launch-type = "singleton" categories = ["com.amazon.category.main"] [wants] [[wants.service]] id = "com.amazon.inputmethod.service" [[wants.service]] id = "com.amazon.network.service" [[wants.service]] id = "com.amazon.iap.core.service" [[wants.module]] id = "/com.amazon.iap.core@IIAPCoreUI" [needs] [[needs.module]] id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" [[needs.module]] id = ${escapeTomlString(VEGA_OS_MODULE)} [os.version] target = ${escapeTomlString(VEGA_OS_VERSION)} min = ${escapeTomlString(VEGA_OS_VERSION)} `; export const createVegaEntryPoint = (): string => `${GENERATED_JS_MARKER} import {AppRegistry} from 'react-native'; import App from './App'; import {name as appName} from './app.json'; AppRegistry.registerComponent(appName, () => App); `; export const createVegaAppJson = ( settings: VegaProjectSettings, ): Record => ({ name: settings.appName, displayName: settings.title, expoIapGenerated: true, }); type MutablePackageJson = { name?: string; scripts?: Record; dependencies?: Record; devDependencies?: Record; optionalDependencies?: Record; kepler?: Record; }; const setIfMissing = ( target: Record, key: string, value: string, ) => { if (!target[key]) { target[key] = value; } }; const setDependency = (pkg: MutablePackageJson, key: string, value: string) => { delete pkg.devDependencies?.[key]; delete pkg.optionalDependencies?.[key]; if (!pkg.dependencies) { pkg.dependencies = {}; } setIfMissing(pkg.dependencies, key, value); }; const setDevDependency = ( pkg: MutablePackageJson, key: string, value: string, ) => { delete pkg.dependencies?.[key]; delete pkg.optionalDependencies?.[key]; if (!pkg.devDependencies) { pkg.devDependencies = {}; } setIfMissing(pkg.devDependencies, key, value); }; const setOptionalDependency = ( pkg: MutablePackageJson, key: string, value: string, ) => { delete pkg.dependencies?.[key]; delete pkg.devDependencies?.[key]; if (!pkg.optionalDependencies) { pkg.optionalDependencies = {}; } setIfMissing(pkg.optionalDependencies, key, value); }; const getVpkgBaseName = ( pkg: MutablePackageJson, settings: VegaProjectSettings, ): string => { const rawName = typeof pkg.name === 'string' && pkg.name.trim() ? pkg.name.split('/').pop() : undefined; return (rawName ?? settings.appName) .replace(/[^A-Za-z0-9_-]/g, '') .toLowerCase(); }; export const mergeVegaPackageJson = ( pkg: T, settings: VegaProjectSettings, ): T & MutablePackageJson => { const next: T & MutablePackageJson = {...pkg}; next.scripts = {...(pkg.scripts ?? {})}; next.dependencies = {...(pkg.dependencies ?? {})}; next.devDependencies = {...(pkg.devDependencies ?? {})}; next.optionalDependencies = {...(pkg.optionalDependencies ?? {})}; const vpkgBaseName = getVpkgBaseName(pkg, settings); setIfMissing( next.scripts, 'vega:prebuild', 'expo prebuild --platform android --no-install', ); setIfMissing( next.scripts, 'build:vega:release', 'expo prebuild --platform android --no-install && react-native build-vega --build-type Release', ); setIfMissing( next.scripts, 'build:vega:debug', 'expo prebuild --platform android --no-install && react-native build-vega --build-type Debug', ); setIfMissing( next.scripts, 'run:vega:firetv', `vega device install-app --packagePath build/armv7-debug/${vpkgBaseName}_armv7.vpkg && vega device launch-app --appName ${settings.componentId}`, ); setDependency( next, '@amazon-devices/keplerscript-appstore-iap-lib', '~2.13.0', ); setDevDependency(next, '@amazon-devices/kepler-cli-platform', '~0.22.0'); setDevDependency( next, '@amazon-devices/kepler-compatibility-metro-config', '^0.0.7', ); setDevDependency( next, '@amazon-devices/kepler-module-resolver-preset', '^0.1.15', ); setOptionalDependency(next, '@amazon-devices/react-native-kepler', '^2.0.0'); setIfMissing(next.devDependencies, '@react-native-community/cli', '11.3.2'); setIfMissing(next.devDependencies, '@react-native/metro-config', '^0.72.6'); setIfMissing(next.devDependencies, 'babel-plugin-module-resolver', '^5.0.2'); setIfMissing(next.devDependencies, '@react-native/babel-preset', '0.76.9'); next.kepler = { ...(pkg.kepler ?? {}), projectType: 'application', appName: settings.appName, targets: ['tv'], os: ['vega'], api: 0.1, }; return next; }; const readFileIfPresent = (filePath: string): string | undefined => { try { return fs.readFileSync(filePath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error; return undefined; } }; // Every Vega build needs a root manifest.toml, so one marks a Vega target. // Kepler dependencies or a kepler package.json field alone do not count: a // multi-target root installs them for a separate Vega target. export const isVegaTargetProject = (projectRoot: string): boolean => { try { return fs.existsSync(path.join(projectRoot, 'manifest.toml')); } catch { return false; } }; // A file without the plugin's marker belongs to the user and is left alone. const syncGeneratedFile = ( filePath: string, content: string, marker: string, ): boolean => { const existing = readFileIfPresent(filePath); if ( existing !== undefined && (!existing.includes(marker) || existing === content) ) { return false; } fs.mkdirSync(path.dirname(filePath), {recursive: true}); fs.writeFileSync(filePath, content, 'utf8'); return true; }; const syncAppJson = ( projectRoot: string, settings: VegaProjectSettings, ): boolean => { const appJsonPath = path.join(projectRoot, 'app.json'); const content = `${JSON.stringify(createVegaAppJson(settings), null, 2)}\n`; const existing = readFileIfPresent(appJsonPath); if (existing !== undefined) { try { const parsed = JSON.parse(existing); if (parsed?.expoIapGenerated !== true) { return false; } } catch { return false; } if (existing === content) return false; } fs.writeFileSync(appJsonPath, content, 'utf8'); return true; }; const syncPackageJson = ( projectRoot: string, settings: VegaProjectSettings, ): boolean => { const packageJsonPath = path.join(projectRoot, 'package.json'); const original = readFileIfPresent(packageJsonPath); if (original === undefined) return false; const parsed = JSON.parse(original); const next = mergeVegaPackageJson(parsed, settings); const serialized = `${JSON.stringify(next, null, 2)}\n`; if (serialized === original) return false; fs.writeFileSync(packageJsonPath, serialized, 'utf8'); return true; }; const syncIconAsset = ( projectRoot: string, config: ExpoConfig, options?: VegaProjectOptions, ): boolean => { const rawIcon = options?.icon ?? config.icon; if (!rawIcon) { WarningAggregator.addWarningAndroid( 'expo-iap', 'Vega manifest requires an icon. Set expo.icon or android.amazon.vegaOS.icon.', ); return false; } const sourcePath = path.resolve(projectRoot, rawIcon); if (!fs.existsSync(sourcePath)) { WarningAggregator.addWarningAndroid( 'expo-iap', `Vega icon not found: ${sourcePath}`, ); return false; } const outputPath = path.join( projectRoot, 'assets', 'image', resolveIconFileName(rawIcon), ); fs.mkdirSync(path.dirname(outputPath), {recursive: true}); if ( fs.existsSync(outputPath) && fs.readFileSync(outputPath).equals(fs.readFileSync(sourcePath)) ) { return false; } fs.copyFileSync(sourcePath, outputPath); return true; }; const withVega: ConfigPlugin = (config, options) => { return withDangerousMod(config, [ 'android', async (modConfig) => { const projectRoot = modConfig.modRequest.projectRoot; const enabled = options?.enabled ?? isVegaTargetProject(projectRoot); if (!enabled) return modConfig; if (options?.enabled === undefined) { logOnce('🔍 expo-iap: Vega target auto-detected; syncing Vega files'); } const settings = resolveVegaProjectSettings( modConfig as ExpoConfig, options ?? undefined, ); const changedManifest = syncGeneratedFile( path.join(projectRoot, 'manifest.toml'), createVegaManifest(settings), GENERATED_MARKER, ); const changedPackage = options?.syncPackageJson === false ? false : syncPackageJson(projectRoot, settings); const changedIcon = syncIconAsset( projectRoot, modConfig as ExpoConfig, options ?? undefined, ); const changedEntry = syncGeneratedFile( path.join(projectRoot, 'index.js'), createVegaEntryPoint(), GENERATED_JS_MARKER, ); const changedAppJson = syncAppJson(projectRoot, settings); if (changedManifest) { logOnce('🛠️ expo-iap: Wrote Vega manifest.toml'); } if (changedPackage) { logOnce('🛠️ expo-iap: Synced Vega package.json metadata'); } if (changedIcon) { logOnce('🛠️ expo-iap: Copied Vega icon asset'); } if (changedEntry) { logOnce('🛠️ expo-iap: Wrote Vega index.js entry point'); } if (changedAppJson) { logOnce('🛠️ expo-iap: Wrote Vega app.json metadata'); } return modConfig; }, ]); }; export default withVega;