🏗️ Migrate to Expo + EAS + CNG (Continuous Native Generation)#617
🏗️ Migrate to Expo + EAS + CNG (Continuous Native Generation)#617guytepper wants to merge 1 commit into
Conversation
Move ios/ and android/ from committed bare projects to generated artifacts
reproduced by `expo prebuild` from app.config.ts + config plugins + targets/.
Foundation:
- app.config.ts (env-driven dev/prod Firebase), eas.json (dev/preview/prod)
- babel-preset-expo + expo/metro-config; registerRootComponent entry
- relocate icons + GoogleService files to repo root; git-ignore ios/ + android/
iOS (@bacons/apple-targets + plugins/withBetterRailIos):
- targets/{widget,intent,watch,watch-widget} reproduce all 5 native targets
(WidgetKit + Live Activities, Siri RouteIntent, watchOS app + watch widget)
- inject RNBetterRail native module + shared Swift + intentdefinition codegen
into the app target; bundle stationsData.json; bridging-header quick-actions
- vendored watch SPM deps (EasySkeleton, HTMLString); build RN from source
Android (plugins/withBetterRailAndroid):
- copy widget Kotlin + resources; Hilt/Room/kapt/DataStore/WorkManager gradle
- manifest widget receivers/activities/service; @HiltAndroidApp + lifecycle;
RNScreens fragment factory + deep-link onNewIntent; jetifier
Verified: both platforms compile from a clean prebuild (iOS all 5 targets embed;
android assembleDebug produces an APK with the widgets registered).
There was a problem hiding this comment.
Code Review
This pull request migrates the project to Expo's Continuous Native Generation (CNG) by removing the bare native directories and introducing custom Expo config plugins and Apple targets to dynamically generate the iOS and Android projects. The code review feedback highlights several critical issues regarding the idempotency and robustness of these new config plugins. Specifically, the plugins do not check for existing entries before adding manifest components or Xcode file references, which will cause duplicate declarations and build failures on subsequent prebuilds. Additionally, several regex replacements in the Kotlin and Gradle modifiers are fragile and prone to failure, and a potential runtime crash was identified in the watch widget's locale utility due to unsafe array indexing.
| const withMainActivityMods = (config) => | ||
| withMainActivity(config, (cfg) => { | ||
| let src = cfg.modResults.contents | ||
|
|
||
| if (!src.includes("RNScreensFragmentFactory")) { | ||
| // imports (Bundle is already imported by the Expo template) | ||
| src = src.replace(/(package [^\n]+\n)/, `$1\n${IMPORTS}\n`) | ||
|
|
||
| // Set the RNScreens fragment factory before the existing super.onCreate(...) call. | ||
| src = src.replace( | ||
| /(\n)(\s*)super\.onCreate\(/, | ||
| `$1$2supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()\n$2super.onCreate(`, | ||
| ) | ||
|
|
||
| // Add onNewIntent as a new method after getMainComponentName(). | ||
| src = src.replace(/(override fun getMainComponentName\(\): String = "[^"]*"\n)/, `$1${ON_NEW_INTENT}`) | ||
| } | ||
|
|
||
| cfg.modResults.contents = src | ||
| return cfg | ||
| }) |
There was a problem hiding this comment.
In the default Expo MainActivity.kt template, the onCreate method is not overridden. Consequently, the regex search for super.onCreate( will fail to match, and the RNScreensFragmentFactory will never be registered. This can lead to fragment restoration crashes on Android. We should check if onCreate exists, and if not, inject the entire method.
const withMainActivityMods = (config) =>
withMainActivity(config, (cfg) => {
let src = cfg.modResults.contents
if (!src.includes("RNScreensFragmentFactory")) {
// imports (Bundle is already imported by the Expo template)
src = src.replace(/(package [^\n]+\n)/, `$1\n${IMPORTS}\n`)
// Set the RNScreens fragment factory. If onCreate doesn't exist in the template, inject it.
if (src.includes("onCreate")) {
src = src.replace(
/(\n)(\s*)super\.onCreate\(/,
`$1$2supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()\n$2super.onCreate(`,
)
} else {
const onCreateMethod = `\n override fun onCreate(savedInstanceState: android.os.Bundle?) {\n supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()\n super.onCreate(savedInstanceState)\n }\n`
// Insert onCreate before the last closing brace of the class
src = src.replace(/(\n}\s*)$/, `\n${onCreateMethod}$1`)
}
// Add onNewIntent as a new method after getMainComponentName().
src = src.replace(/(override fun getMainComponentName\(\): String = "[^\"]*"\n)/, `$1${ON_NEW_INTENT}`)
}
cfg.modResults.contents = src
return cfg
})| const withAndroidManifestMods = (config) => | ||
| withAndroidManifest(config, (cfg) => { | ||
| const app = cfg.modResults.manifest.application[0] | ||
|
|
||
| app.activity = app.activity || [] | ||
| app.receiver = app.receiver || [] | ||
| app.service = app.service || [] | ||
| app["meta-data"] = app["meta-data"] || [] | ||
|
|
||
| app.activity.push( | ||
| configActivity(".widget.CompactWidget2x2ConfigActivity"), | ||
| configActivity(".widget.CompactWidget4x2ConfigActivity"), | ||
| ) | ||
|
|
||
| app.receiver.push( | ||
| widgetReceiver( | ||
| ".widget.ModernCompactWidget2x2Provider", | ||
| "com.betterrail.widget.modern.compact.ACTION_REFRESH", | ||
| "com.betterrail.widget.modern.compact.ACTION_WIDGET_UPDATE", | ||
| "@xml/compact_widget_2x2_info", | ||
| ), | ||
| widgetReceiver( | ||
| ".widget.ModernCompactWidget4x2Provider", | ||
| "com.betterrail.widget.modern.compact4x2.ACTION_REFRESH", | ||
| "com.betterrail.widget.modern.compact4x2.ACTION_WIDGET_UPDATE", | ||
| "@xml/compact_widget_4x2_info", | ||
| ), | ||
| { | ||
| $: { "android:name": ".widget.scheduler.WidgetUpdateReceiver", "android:exported": "false" }, | ||
| "intent-filter": [ | ||
| { action: [{ $: { "android:name": "com.betterrail.widget.UPDATE_ALL_WIDGETS" } }] }, | ||
| { | ||
| action: [{ $: { "android:name": "android.intent.action.BOOT_COMPLETED" } }], | ||
| category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }], | ||
| }, | ||
| { | ||
| action: [{ $: { "android:name": "android.intent.action.MY_PACKAGE_REPLACED" } }], | ||
| category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }], | ||
| data: [{ $: { "android:scheme": "package" } }], | ||
| }, | ||
| { | ||
| action: [{ $: { "android:name": "android.intent.action.PACKAGE_REPLACED" } }], | ||
| category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }], | ||
| data: [{ $: { "android:scheme": "package" } }], | ||
| }, | ||
| ], | ||
| }, | ||
| ) | ||
|
|
||
| app.service.push({ | ||
| $: { | ||
| "android:name": ".widget.TrainWidgetService", | ||
| "android:permission": "android.permission.BIND_REMOTEVIEWS", | ||
| "android:exported": "false", | ||
| }, | ||
| }) | ||
|
|
||
| app["meta-data"].push({ | ||
| $: { | ||
| "android:name": "com.google.firebase.messaging.default_notification_icon", | ||
| "android:resource": "@drawable/notification_icon", | ||
| }, | ||
| }) | ||
|
|
||
| return cfg | ||
| }) |
There was a problem hiding this comment.
This plugin blindly pushes activities, receivers, services, and metadata into the manifest arrays on every prebuild. Since Expo preserves the android directory between prebuilds unless --clean is specified, running expo prebuild multiple times will accumulate duplicate declarations in AndroidManifest.xml, leading to manifest merger and build failures. We should use a helper function to ensure each component is added only once.
const pushUnique = (array, item, key = "android:name") => {
const exists = array.some((x) => x.$ && x.$[key] === item.$[key])
if (!exists) {
array.push(item)
}
}
const withAndroidManifestMods = (config) =>
withAndroidManifest(config, (cfg) => {
const app = cfg.modResults.manifest.application[0]
app.activity = app.activity || []
app.receiver = app.receiver || []
app.service = app.service || []
app["meta-data"] = app["meta-data"] || []
pushUnique(app.activity, configActivity(".widget.CompactWidget2x2ConfigActivity"))
pushUnique(app.activity, configActivity(".widget.CompactWidget4x2ConfigActivity"))
pushUnique(app.receiver, widgetReceiver(
".widget.ModernCompactWidget2x2Provider",
"com.betterrail.widget.modern.compact.ACTION_REFRESH",
"com.betterrail.widget.modern.compact.ACTION_WIDGET_UPDATE",
"@xml/compact_widget_2x2_info",
))
pushUnique(app.receiver, widgetReceiver(
".widget.ModernCompactWidget4x2Provider",
"com.betterrail.widget.modern.compact4x2.ACTION_REFRESH",
"com.betterrail.widget.modern.compact4x2.ACTION_WIDGET_UPDATE",
"@xml/compact_widget_4x2_info",
))
pushUnique(app.receiver, {
$: { "android:name": ".widget.scheduler.WidgetUpdateReceiver", "android:exported": "false" },
"intent-filter": [
{ action: [{ $: { "android:name": "com.betterrail.widget.UPDATE_ALL_WIDGETS" } }] },
{
action: [{ $: { "android:name": "android.intent.action.BOOT_COMPLETED" } }],
category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }],
},
{
action: [{ $: { "android:name": "android.intent.action.MY_PACKAGE_REPLACED" } }],
category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }],
data: [{ $: { "android:scheme": "package" } }],
},
{
action: [{ $: { "android:name": "android.intent.action.PACKAGE_REPLACED" } }],
category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }],
data: [{ $: { "android:scheme": "package" } }],
},
],
})
pushUnique(app.service, {
$: {
"android:name": ".widget.TrainWidgetService",
"android:permission": "android.permission.BIND_REMOTEVIEWS",
"android:exported": "false"
}
})
pushUnique(app["meta-data"], {
$: {
"android:name": "com.google.firebase.messaging.default_notification_icon",
"android:resource": "@drawable/notification_icon"
}
})
return cfg
})| const src = path.join(projectRoot, relSrc) | ||
| fs.copyFileSync(src, path.join(destDir, destName)) | ||
| } | ||
|
|
||
| // Compiled Swift + ObjC sources -> Sources build phase of the app target. | ||
| // File paths are basenames: the group's own path (BetterRail/BetterRailNative) supplies | ||
| // the prefix, so passing the subdir again would double it. | ||
| for (const [relSrc, destName] of [...SWIFT_FILES, ...OBJC_FILES]) { | ||
| copy(relSrc, destName) | ||
| proj.addSourceFile(destName, { target: targetKey }, groupKey) | ||
| } | ||
|
|
||
| // Intent definition -> Sources phase (triggers RouteIntent codegen). | ||
| copy(INTENT_DEF[0], INTENT_DEF[1]) | ||
| proj.addSourceFile(INTENT_DEF[1], { target: targetKey }, groupKey) | ||
|
|
||
| // Bundled resources -> Copy Bundle Resources phase (loaded via Bundle.main at runtime). |
There was a problem hiding this comment.
The plugin adds files to the Xcode project using proj.addSourceFile and proj.addResourceFile on every prebuild. If expo prebuild is run multiple times without --clean, these files will be added repeatedly to the pbxproj file, creating duplicate file references and build phase entries, which leads to duplicate symbol errors or multiple build command errors in Xcode. We should check if the file reference already exists in the project before adding it to ensure idempotency.
const hasFile = (name) => {
const fileRefSection = proj.hash.project.objects.PBXFileReference;
for (const key in fileRefSection) {
if (fileRefSection[key].path === name || fileRefSection[key].name === name) {
return true;
}
}
return false;
}
const copy = (relSrc, destName) => {
const src = path.join(projectRoot, relSrc)
fs.copyFileSync(src, path.join(destDir, destName))
}
// Compiled Swift + ObjC sources -> Sources build phase of the app target.
for (const [relSrc, destName] of [...SWIFT_FILES, ...OBJC_FILES]) {
copy(relSrc, destName)
if (!hasFile(destName)) {
proj.addSourceFile(destName, { target: targetKey }, groupKey)
}
}
// Intent definition -> Sources phase (triggers RouteIntent codegen).
copy(INTENT_DEF[0], INTENT_DEF[1])
if (!hasFile(INTENT_DEF[1])) {
proj.addSourceFile(INTENT_DEF[1], { target: targetKey }, groupKey)
}
// Bundled resources -> Copy Bundle Resources phase (loaded via Bundle.main at runtime).
for (const [relSrc, destName] of RESOURCE_FILES) {
copy(relSrc, destName)
if (!hasFile(destName)) {
proj.addResourceFile(destName, { target: targetKey }, groupKey)
}
}| src = mergeContents({ | ||
| tag: "better-rail-android-plugins", | ||
| src, | ||
| newSrc: APP_PLUGINS, | ||
| anchor: /apply plugin: "com\.facebook\.react"/, | ||
| offset: 1, | ||
| comment: "//", | ||
| }).contents |
There was a problem hiding this comment.
The regex /apply plugin: "com\.facebook\.react"/ is used to anchor the injection of Hilt and kapt plugins. However, Gradle files can use single quotes (apply plugin: 'com.facebook.react') or modern plugins { id(...) } syntax. If the template uses single quotes or slightly different spacing, the regex will fail to match, and the plugins won't be applied, breaking the build. We should use a more robust regex that matches both single and double quotes, and optional spaces.
| src = mergeContents({ | |
| tag: "better-rail-android-plugins", | |
| src, | |
| newSrc: APP_PLUGINS, | |
| anchor: /apply plugin: "com\.facebook\.react"/, | |
| offset: 1, | |
| comment: "//", | |
| }).contents | |
| src = mergeContents({ | |
| tag: "better-rail-android-plugins", | |
| src, | |
| newSrc: APP_PLUGINS, | |
| anchor: /apply plugin:\s*['"]com\.facebook\.react['"]/, | |
| offset: 1, | |
| comment: "//", | |
| }).contents |
| let langCode = Bundle.main.preferredLocalizations[0] | ||
| let usLocale = Locale(identifier: "en-US") | ||
| var langName = SupportedLanguages.english | ||
|
|
||
| if let languageName = usLocale.localizedString(forLanguageCode: langCode)?.lowercased() { | ||
| if let languageValue = SupportedLanguages(rawValue: languageName) { | ||
| langName = languageValue | ||
| } | ||
| } | ||
|
|
||
| return langName | ||
| } | ||
|
|
There was a problem hiding this comment.
In getUserLocale(), the code accesses Bundle.main.preferredLocalizations[0] directly. If preferredLocalizations is empty, this will cause a runtime crash due to out-of-bounds array access. It is safer to use Bundle.main.preferredLocalizations.first with a guard statement.
func getUserLocale() -> SupportedLanguages {
guard let langCode = Bundle.main.preferredLocalizations.first else {
return .english
}
let usLocale = Locale(identifier: "en-US")
var langName = SupportedLanguages.english
if let languageName = usLocale.localizedString(forLanguageCode: langCode)?.lowercased() {
if let languageValue = SupportedLanguages(rawValue: languageName) {
langName = languageValue
}
}
return langName
}| // onCreate init (after super.onCreate()) | ||
| src = src.replace(/(override fun onCreate\(\)\s*{\s*\n\s*super\.onCreate\(\)\n)/, `$1${ONCREATE_INIT}\n`) | ||
| // lifecycle override methods before the final closing brace of the file |
There was a problem hiding this comment.
The regex /(override fun onCreate\(\)\s*{\s*\n\s*super\.onCreate\(\)\n)/ is used to inject the widget lifecycle initialization. This regex is highly sensitive to formatting, spacing, and line endings (e.g., \r\n on Windows). Replacing super.onCreate() directly is much simpler and more robust.
| // onCreate init (after super.onCreate()) | |
| src = src.replace(/(override fun onCreate\(\)\s*{\s*\n\s*super\.onCreate\(\)\n)/, `$1${ONCREATE_INIT}\n`) | |
| // lifecycle override methods before the final closing brace of the file | |
| // onCreate init (after super.onCreate()) | |
| src = src.replace("super.onCreate()", `super.onCreate()\n${ONCREATE_INIT}`) |
Summary
Migrates Better Rail from a bare workflow (committed, hand-maintained
ios/+android/) to Continuous Native Generation. The native projects are now git-ignored and regenerated byexpo prebuildfromapp.config.ts+ config plugins +targets/. Builds run on EAS.All existing native targets are preserved and reproduced declaratively.
What changed
Foundation
app.config.ts— single source of truth; env-driven (APP_VARIANT) dev/prod Firebase, Info.plist, entitlements (App Group, keychain, Live Activities), URL schemes, fonts, plugins.eas.json—development/preview/productionprofiles.babel-preset-expo+expo/metro-config; entry viaregisterRootComponent.ios/+android/git-ignored.iOS —
@bacons/apple-targets(targets/) +plugins/withBetterRailIosBetterRailWidget(WidgetKit + Live Activities),StationIntent(SiriRouteIntent),BetterRailWatch(watchOS app),BetterRailWidgetWatch.RNBetterRailnative module + shared Swift +Route.intentdefinition(app-side codegen) into the app target; bundlesstationsData.json; quick-actions handler via a bridging header.RCT-Follyresolves under SDK 54).Android —
plugins/withBetterRailAndroid@HiltAndroidApp+ widget lifecycle; RNScreens fragment factory + deep-linkonNewIntent; Jetifier.Source of truth lives in
ios-native/andandroid-native/(the plugins copy from these on every prebuild).Verification
expo prebuild --clean→pod install→xcodebuild→ all 5 targets compile and embed (widget + intent in the app; watch app embedded with its widget; watch built for watchOS). Verified running on simulator incl. the search →donateRouteIntentflow../gradlew assembleDebug→ APK built with both widget providers, config activities, the update receiver, and the RemoteViews service registered.Follow-ups for maintainers (not in this PR)
eas initto setextra.eas.projectId, theneas build. Signed builds need the App Group capability registered for all 5 bundle ids + push entitlement (TeamUE6BVYPPFX).GoogleService-Info.plist/google-services.jsonto CI/EAS as secrets (git-ignored at repo root).detoxbuild commands still pass-sdk iphonesimulator(would force watch targets to iOS) — adjust if Detox is kept.🤖 Generated with Claude Code