From 32fae1182c3a6d3febdf1e8ca31c5e01eb0258c2 Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 27 Jul 2026 20:46:32 -0300 Subject: [PATCH 1/2] fix: throw when a native file anchor is missing instead of no-op `replaceIfNotFound` and `addBelowAnchorIfNotFound` both ended up calling `String.replace` with a needle that wasn't there, which returns the input untouched, so a mod that found none of its anchors reported success and wrote nothing. That is how #30 sat unnoticed across five SDK releases while the Android side, which throws, got reported the week SDK 57 landed. `addBelowAnchorIfNotFound` did have a throw, but behind the wrong check: it ran the replace first and only tested the anchor when the insertion was already present, so the one case worth catching fell through. Both now check for the change being already applied, then throw on a missing anchor, then edit. Android already guarded every call with its own `includes` check, so nothing changes there. Claude-Session: https://claude.ai/code/session_01QsVEsfkynXpJWK4t33exG9 --- src/utils/__tests__/anchors.test.ts | 54 ++++++++++++++++++++++ src/utils/add-below-anchor-if-not-found.ts | 23 +++++++-- src/utils/replace-if-not-found.ts | 30 ++++++++++-- 3 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 src/utils/__tests__/anchors.test.ts diff --git a/src/utils/__tests__/anchors.test.ts b/src/utils/__tests__/anchors.test.ts new file mode 100644 index 0000000..f635bdb --- /dev/null +++ b/src/utils/__tests__/anchors.test.ts @@ -0,0 +1,54 @@ +import { addBelowAnchorIfNotFound } from "../add-below-anchor-if-not-found"; +import { replaceIfNotFound } from "../replace-if-not-found"; + +describe("addBelowAnchorIfNotFound", () => { + it("inserts below the anchor", () => { + expect(addBelowAnchorIfNotFound("a\nb\n", "a", "new")).toBe("a\nnew\nb\n"); + }); + + it("does not insert twice", () => { + const once = addBelowAnchorIfNotFound("a\nb\n", "a", "new"); + + expect(addBelowAnchorIfNotFound(once, "a", "new")).toBe(once); + }); + + // The two checks used to run the other way round, so a missing anchor fell + // into a `String.replace` that matched nothing and returned the input. + it("throws on a missing anchor rather than returning the input", () => { + expect(() => addBelowAnchorIfNotFound("a\nb\n", "missing", "new")).toThrow( + /anchor string "missing"/, + ); + }); + + it("stays quiet when the insertion is there and the anchor is gone", () => { + expect(addBelowAnchorIfNotFound("new\n", "missing", "new")).toBe("new\n"); + }); +}); + +describe("replaceIfNotFound", () => { + it("replaces the target", () => { + expect(replaceIfNotFound("a b c", "b", "B")).toBe("a B c"); + }); + + it("replaces only the first occurrence", () => { + expect(replaceIfNotFound("b b", "b", "B")).toBe("B b"); + }); + + it("does not replace twice", () => { + const once = replaceIfNotFound("a b c", "b", "B"); + + expect(replaceIfNotFound(once, "b", "B")).toBe(once); + }); + + it("throws when there is nothing to replace", () => { + expect(() => replaceIfNotFound("a c", "b", "B")).toThrow( + /expected to find "b"/, + ); + }); + + it("puts the caller's description in the error", () => { + expect(() => + replaceIfNotFound("a c", "b", "B", "Cannot do the thing"), + ).toThrow(/Cannot do the thing/); + }); +}); diff --git a/src/utils/add-below-anchor-if-not-found.ts b/src/utils/add-below-anchor-if-not-found.ts index 47a4d71..ed3b529 100644 --- a/src/utils/add-below-anchor-if-not-found.ts +++ b/src/utils/add-below-anchor-if-not-found.ts @@ -1,11 +1,26 @@ +/** + * Inserts a line below an anchor, and throws if the anchor isn't there. + * + * The two checks used to run the other way round, which made the throw + * unreachable in the case that matters: a missing anchor fell into + * `String.replace`, which returns the input untouched when the needle is + * absent, so the caller got a silent no-op. A missing anchor now throws, and + * an insertion that's already applied returns early. + * + * @param originalString The file contents. + * @param anchor The line to insert below. + * @param stringToBeAdded The line to insert. + * @returns The contents with the line inserted, or unchanged if it was already + * there. + */ export function addBelowAnchorIfNotFound( originalString: string, anchor: string, stringToBeAdded: string, ) { - // Make sure the original does not contain the new string - if (!originalString.includes(stringToBeAdded)) { - return originalString.replace(anchor, `${anchor}\n${stringToBeAdded}`); + // Already applied, so the anchor has done its job whether or not it survived. + if (originalString.includes(stringToBeAdded)) { + return originalString; } if (!originalString.includes(anchor)) { @@ -14,5 +29,5 @@ export function addBelowAnchorIfNotFound( ); } - return originalString; + return originalString.replace(anchor, `${anchor}\n${stringToBeAdded}`); } diff --git a/src/utils/replace-if-not-found.ts b/src/utils/replace-if-not-found.ts index 23d12e6..28863b1 100644 --- a/src/utils/replace-if-not-found.ts +++ b/src/utils/replace-if-not-found.ts @@ -1,12 +1,34 @@ +/** + * Swaps one string for another, once, and throws if there was nothing to swap. + * + * The throw is the point. This used to hand back the input untouched when + * `stringToBeReplaced` was missing, because `String.replace` on a needle that + * isn't there is a no-op, so prebuild reported success while writing nothing. + * That is how #30 went unnoticed for five SDK releases. + * + * @param originalString The file contents. + * @param stringToBeReplaced The text to look for. + * @param newStringToReplace The text to put in its place. + * @param description What the caller was trying to do, used in the error. + * @returns The replaced contents, or the input unchanged if the replacement is + * already there. + */ export function replaceIfNotFound( originalString: string, stringToBeReplaced: string, newStringToReplace: string, + description?: string, ) { - // Make sure the original does not contain the new string - if (!originalString.includes(newStringToReplace)) { - return originalString.replace(stringToBeReplaced, newStringToReplace); + // Already applied, so there is nothing to look for. + if (originalString.includes(newStringToReplace)) { + return originalString; } - return originalString; + if (!originalString.includes(stringToBeReplaced)) { + throw new Error( + `${description ?? "Cannot apply the CodePush change"}: expected to find "${stringToBeReplaced}" and it is not there.`, + ); + } + + return originalString.replace(stringToBeReplaced, newStringToReplace); } From 28ba2e1c8175517f3217df4ec76da169f23a22b0 Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 27 Jul 2026 20:46:32 -0300 Subject: [PATCH 2/2] fix(ios): redirect the bundle URL on Expo SDK 53's Swift AppDelegate The mod looked for `#import "AppDelegate.h"` and the Objective-C `[[NSBundle mainBundle] URLForResource:@"main" ...]` line. SDK 53 rewrote the template in Swift, so from 53 through 57 neither anchor is in the file and the app kept booting from the bundle baked into the IPA. Swift gets the release arm of `ReactNativeDelegate.bundleURL()` swapped for `CodePush.bundleURL()`. The `#if DEBUG` arm stays on Metro. That block is byte-identical across 53 to 57, and the pattern tolerates the call being reflowed. Swift cannot import CodePush directly, since it is an Objective-C pod with no module map, so `#import ` goes into the bridging header instead. Its path comes from the Xcode project's `SWIFT_OBJC_BRIDGING_HEADER` rather than from guessing at the project name. Objective-C app delegates import CodePush themselves and skip it. SDK 50 through 52 keep the Objective-C path unchanged. Closes #30 Claude-Session: https://claude.ai/code/session_01QsVEsfkynXpJWK4t33exG9 --- .gitignore | 4 + src/index.ts | 2 + src/ios/__tests__/app-delegate.test.ts | 199 +++++++++++++++++ src/ios/__tests__/templates.ts | 289 +++++++++++++++++++++++++ src/ios/app-delegate-dependency.ts | 84 ++++++- src/ios/bridging-header-dependency.ts | 82 +++++++ 6 files changed, 648 insertions(+), 12 deletions(-) create mode 100644 src/ios/__tests__/app-delegate.test.ts create mode 100644 src/ios/__tests__/templates.ts create mode 100644 src/ios/bridging-header-dependency.ts diff --git a/.gitignore b/.gitignore index 2b102f6..c10aa85 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ node_modules # Generated by the `build` script, which the `prepare` script runs on every # install and on publish. build + +# `npm pack` output, which is how the plugin gets tested against a real app +# before publishing. +*.tgz diff --git a/src/index.ts b/src/index.ts index 87fed1d..e257917 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { withAndroidMainApplicationDependency } from "./android/main-application import { withAndroidSettingsDependency } from "./android/settings-dependency"; import { withAndroidStringsDependency } from "./android/strings-dependency"; import { withIosAppDelegateDependency } from "./ios/app-delegate-dependency"; +import { withIosBridgingHeaderDependency } from "./ios/bridging-header-dependency"; import { withIosInfoPlistDependency } from "./ios/info-plist-dependency"; // @todo: Is this still needed? @@ -35,6 +36,7 @@ const withRnCodepush: ConfigPlugin = (config, props) => { // Apply iOS changes config = withIosInfoPlistDependency(config, props); config = withIosAppDelegateDependency(config, props); + config = withIosBridgingHeaderDependency(config, props); return config; }; diff --git a/src/ios/__tests__/app-delegate.test.ts b/src/ios/__tests__/app-delegate.test.ts new file mode 100644 index 0000000..5971d99 --- /dev/null +++ b/src/ios/__tests__/app-delegate.test.ts @@ -0,0 +1,199 @@ +import { applyAppDelegate } from "../app-delegate-dependency"; +import { + applyBridgingHeader, + findBridgingHeaderPath, +} from "../bridging-header-dependency"; +import { + bridgingHeader, + sdk50AppDelegate, + sdk52AppDelegate, + sdk53AppDelegate, + sdk57AppDelegate, +} from "./templates"; + +const swiftTemplates = [ + ["SDK 53", sdk53AppDelegate], + ["SDK 57", sdk57AppDelegate], +] as const; + +const objcTemplates = [ + ["SDK 50", sdk50AppDelegate], + ["SDK 52", sdk52AppDelegate], +] as const; + +describe("AppDelegate.swift", () => { + it.each(swiftTemplates)("redirects the release bundle on %s", (_, source) => { + expect(source).toContain( + 'Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + ); + + const result = applyAppDelegate(source, "swift"); + + expect(result).toContain("return CodePush.bundleURL()"); + expect(result).not.toContain('Bundle.main.url(forResource: "main"'); + }); + + it.each(swiftTemplates)( + "leaves the Metro branch on %s alone", + (_, source) => { + const result = applyAppDelegate(source, "swift"); + + expect(result).toContain( + 'RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")', + ); + expect(result).toContain("#if DEBUG"); + }, + ); + + it("changes nothing but that one expression", () => { + const result = applyAppDelegate(sdk57AppDelegate, "swift"); + + expect(result).toBe( + sdk57AppDelegate.replace( + 'Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + "CodePush.bundleURL()", + ), + ); + }); + + it("adds no import, because the bridging header carries it", () => { + const result = applyAppDelegate(sdk57AppDelegate, "swift"); + + expect(result).not.toContain("#import"); + expect(result).not.toContain("import CodePush"); + }); + + it.each(swiftTemplates)("is idempotent on %s", (_, source) => { + const once = applyAppDelegate(source, "swift"); + + expect(applyAppDelegate(once, "swift")).toBe(once); + }); + + it("tolerates the call being reflowed across lines", () => { + const reflowed = sdk57AppDelegate.replace( + 'Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + 'Bundle.main.url(\n forResource: "main",\n withExtension: "jsbundle"\n )', + ); + + expect(applyAppDelegate(reflowed, "swift")).toContain( + "return CodePush.bundleURL()", + ); + }); + + it("throws instead of quietly doing nothing", () => { + const customised = sdk57AppDelegate.replace( + 'Bundle.main.url(forResource: "main", withExtension: "jsbundle")', + "myOwnBundleURL()", + ); + + expect(() => applyAppDelegate(customised, "swift")).toThrow( + /no `Bundle\.main\.url/, + ); + }); + + it("says what to do when it throws", () => { + expect(() => applyAppDelegate("class AppDelegate {}", "swift")).toThrow( + /CodePush\.bundleURL\(\)/, + ); + }); +}); + +describe("AppDelegate.mm", () => { + it.each(objcTemplates)("redirects the release bundle on %s", (_, source) => { + const result = applyAppDelegate(source, "objcpp"); + + expect(result).toContain("return [CodePush bundleURL];"); + expect(result).toContain("#import "); + expect(result).not.toContain('URLForResource:@"main"'); + }); + + it.each(objcTemplates)("leaves the Metro branch on %s alone", (_, source) => { + const result = applyAppDelegate(source, "objcpp"); + + expect(result).toContain( + '[[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"]', + ); + }); + + it("puts the import below the AppDelegate header", () => { + const result = applyAppDelegate(sdk52AppDelegate, "objcpp"); + + expect(result).toContain( + '#import "AppDelegate.h"\n#import ', + ); + }); + + it.each(objcTemplates)("is idempotent on %s", (_, source) => { + const once = applyAppDelegate(source, "objcpp"); + + expect(applyAppDelegate(once, "objcpp")).toBe(once); + }); + + it("throws when the bundle line is gone", () => { + const customised = sdk52AppDelegate.replace( + '[[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]', + "[self myOwnBundleURL]", + ); + + expect(() => applyAppDelegate(customised, "objcpp")).toThrow( + /NSBundle mainBundle/, + ); + }); + + it("throws when the import anchor is gone", () => { + const customised = sdk52AppDelegate.replace( + '#import "AppDelegate.h"', + '#import "MyAppDelegate.h"', + ); + + expect(() => applyAppDelegate(customised, "objcpp")).toThrow( + /anchor string/, + ); + }); +}); + +describe("bridging header", () => { + it("adds the CodePush import to Expo's empty header", () => { + const result = applyBridgingHeader(bridgingHeader); + + expect(result).toContain("#import "); + expect(result.startsWith(bridgingHeader.trimEnd())).toBe(true); + }); + + it("does not add it twice", () => { + const once = applyBridgingHeader(bridgingHeader); + + expect(applyBridgingHeader(once)).toBe(once); + }); + + it("keeps imports a project already had", () => { + const existing = `${bridgingHeader}\n#import \n`; + + expect(applyBridgingHeader(existing)).toContain( + "#import ", + ); + }); + + it("reads the header path out of the Xcode project", () => { + const pbxproj = ` +\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = "sdk57app/sdk57app-Bridging-Header.h"; +\t\t\t\tSWIFT_VERSION = 5.0; +`; + + expect(findBridgingHeaderPath(pbxproj)).toBe( + "sdk57app/sdk57app-Bridging-Header.h", + ); + }); + + it("reads an unquoted header path", () => { + expect( + findBridgingHeaderPath("SWIFT_OBJC_BRIDGING_HEADER = Bridging-Header.h;"), + ).toBe("Bridging-Header.h"); + }); + + it("throws when the project has no bridging header", () => { + expect(() => findBridgingHeaderPath("SWIFT_VERSION = 5.0;")).toThrow( + /SWIFT_OBJC_BRIDGING_HEADER/, + ); + }); +}); diff --git a/src/ios/__tests__/templates.ts b/src/ios/__tests__/templates.ts new file mode 100644 index 0000000..71e7bbc --- /dev/null +++ b/src/ios/__tests__/templates.ts @@ -0,0 +1,289 @@ +/** + * Verbatim `expo-template-bare-minimum` sources, which is what `expo prebuild` + * lays down before any mod runs. Regenerate with + * `npm pack expo-template-bare-minimum@sdk-`. + */ + +/** SDK 50. Objective-C, and the bundle accessor is still `getBundleURL`. */ +export const sdk50AppDelegate = `#import "AppDelegate.h" + +#import +#import + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + self.moduleName = @"main"; + + // You can add your custom initial props in the dictionary below. + // They will be passed down to the ViewController used by React Native. + self.initialProps = @{}; + + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge +{ + return [self getBundleURL]; +} + +- (NSURL *)getBundleURL +{ +#if DEBUG + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"]; +#else + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +#endif +} + +// Linking API +- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options { + return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options]; +} + +// Universal Links +- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler { + BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; + return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken +{ + return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error +{ + return [super application:application didFailToRegisterForRemoteNotificationsWithError:error]; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler +{ + return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; +} + +@end +`; + +/** SDK 52, the last Objective-C template. */ +export const sdk52AppDelegate = `#import "AppDelegate.h" + +#import +#import + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + self.moduleName = @"main"; + + // You can add your custom initial props in the dictionary below. + // They will be passed down to the ViewController used by React Native. + self.initialProps = @{}; + + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge +{ + return [self bundleURL]; +} + +- (NSURL *)bundleURL +{ +#if DEBUG + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"]; +#else + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +#endif +} + +// Linking API +- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options { + return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options]; +} + +// Universal Links +- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler { + BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; + return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken +{ + return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error +{ + return [super application:application didFailToRegisterForRemoteNotificationsWithError:error]; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler +{ + return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; +} + +@end +`; + +/** SDK 53, the first Swift template. */ +export const sdk53AppDelegate = `import Expo +import React +import ReactAppDependencyProvider + +@UIApplicationMain +public class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + bindReactNativeFactory(factory) + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} +`; + +/** + * SDK 57. Identical to 55 and 56, and differs from 53 and 54 only in the + * `@main` attribute and the `internal import`, none of which the mod touches. + */ +export const sdk57AppDelegate = `internal import Expo +import React +import ReactAppDependencyProvider + +@main +class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} +`; + +/** The bridging header Expo generates, byte for byte, from SDK 53 on. */ +export const bridgingHeader = `// +// Use this file to import your target's public headers that you would like to expose to Swift. +// +`; diff --git a/src/ios/app-delegate-dependency.ts b/src/ios/app-delegate-dependency.ts index 7d8801b..ad59fb0 100644 --- a/src/ios/app-delegate-dependency.ts +++ b/src/ios/app-delegate-dependency.ts @@ -1,5 +1,5 @@ import type { PluginConfigType } from "../plugin-config"; -import type { ConfigPlugin } from "expo/config-plugins"; +import type { ConfigPlugin, IOSConfig } from "expo/config-plugins"; import { withAppDelegate } from "expo/config-plugins"; @@ -7,24 +7,84 @@ import { addBelowAnchorIfNotFound } from "../utils/add-below-anchor-if-not-found import { replaceIfNotFound } from "../utils/replace-if-not-found"; /** - * Makes the app delegate aware of the CodePush bundle location. + * What Expo reports for the app delegate it found. Taken from the function that + * decides it rather than spelled out here, so a new language arrives as a type + * error instead of a silently unhandled branch. + */ +type AppDelegateLanguage = ReturnType< + typeof IOSConfig.Paths.getAppDelegate +>["language"]; + +/** Once this reads back, the file has already been through here. */ +const SWIFT_BUNDLE_URL = "CodePush.bundleURL()"; + +const OBJC_BUNDLE_URL = "[CodePush bundleURL]"; + +/** + * The release half of the template's `bundleURL`. The `#if DEBUG` half stays + * put: dev builds have to keep loading from Metro, and CodePush has nothing to + * serve them. + * + * Written as a pattern because Expo has reflowed the call's whitespace more + * than once and the argument labels are the only stable part. + */ +const SWIFT_BINARY_BUNDLE = + /Bundle\.main\.url\(\s*forResource:\s*"main",\s*withExtension:\s*"jsbundle"\s*\)/; + +const OBJC_BINARY_BUNDLE = `[[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]`; + +/** + * Points the React runtime at whichever bundle CodePush picked. + * + * Expo SDK 53 rewrote `AppDelegate` in Swift, where the release bundle comes + * from `ReactNativeDelegate.bundleURL()` and there's no `#import` line to hang + * anything off. The Swift branch rewrites that one expression and leaves the + * import to the bridging header, which `withIosBridgingHeaderDependency` + * handles. SDK 50 through 52 stay on the Objective-C `AppDelegate.mm`. + * + * @throws If the file has neither shape. Failing quietly here is what #30 was. + */ +export function applyAppDelegate( + contents: string, + language: AppDelegateLanguage, +) { + if (language === "swift") { + if (contents.includes(SWIFT_BUNDLE_URL)) return contents; + + if (!SWIFT_BINARY_BUNDLE.test(contents)) { + throw new Error( + `Cannot point AppDelegate.swift at the CodePush bundle: there is no \`Bundle.main.url(forResource: "main", withExtension: "jsbundle")\` to replace. Expo's template returns that from \`ReactNativeDelegate.bundleURL()\`. If yours is customised, return \`${SWIFT_BUNDLE_URL}\` from there and the plugin will leave the file alone.`, + ); + } + + return contents.replace(SWIFT_BINARY_BUNDLE, SWIFT_BUNDLE_URL); + } + + const withImport = addBelowAnchorIfNotFound( + contents, + `#import "AppDelegate.h"`, + `#import `, + ); + + return replaceIfNotFound( + withImport, + OBJC_BINARY_BUNDLE, + OBJC_BUNDLE_URL, + "Cannot point AppDelegate at the CodePush bundle", + ); +} + +/** + * Makes the app delegate ask CodePush where the bundle is. * https://github.com/microsoft/react-native-code-push/blob/master/docs/setup-ios.md */ export const withIosAppDelegateDependency: ConfigPlugin = ( config, - _props, ) => { return withAppDelegate(config, (appDelegateProps) => { - appDelegateProps.modResults.contents = addBelowAnchorIfNotFound( - appDelegateProps.modResults.contents, - `#import "AppDelegate.h"`, - `#import `, - ); - - appDelegateProps.modResults.contents = replaceIfNotFound( + appDelegateProps.modResults.contents = applyAppDelegate( appDelegateProps.modResults.contents, - `return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];`, - `return [CodePush bundleURL];`, + appDelegateProps.modResults.language, ); return appDelegateProps; diff --git a/src/ios/bridging-header-dependency.ts b/src/ios/bridging-header-dependency.ts new file mode 100644 index 0000000..b61120b --- /dev/null +++ b/src/ios/bridging-header-dependency.ts @@ -0,0 +1,82 @@ +import type { PluginConfigType } from "../plugin-config"; +import type { ConfigPlugin } from "expo/config-plugins"; + +import fs from "node:fs"; +import path from "node:path"; + +import { IOSConfig, withDangerousMod } from "expo/config-plugins"; + +const CODE_PUSH_IMPORT = "#import "; + +/** + * `SWIFT_OBJC_BRIDGING_HEADER` as Xcode writes it, quoted or bare, with the + * path relative to the `ios/` directory. + */ +const BRIDGING_HEADER_SETTING = /SWIFT_OBJC_BRIDGING_HEADER = "?([^"\n;]+)"?;/; + +/** Appends the CodePush import to a bridging header. */ +export function applyBridgingHeader(contents: string) { + if (contents.includes(CODE_PUSH_IMPORT)) return contents; + + return `${contents.trimEnd()}\n\n${CODE_PUSH_IMPORT}\n`; +} + +/** + * Reads the bridging header's path out of the Xcode project. + * + * Expo's Swift template ships a `-Bridging-Header.h` and points both + * build configurations at it, so the build setting is the honest source for + * where it lives rather than guessing from the project name. + * + * @throws If no configuration sets it, in which case Swift has no way to see + * CodePush's headers and there is nothing useful to write. + */ +export function findBridgingHeaderPath(pbxproj: string) { + const relativePath = BRIDGING_HEADER_SETTING.exec(pbxproj)?.[1]?.trim(); + + if (!relativePath) { + throw new Error( + "Cannot import CodePush into Swift: the Xcode project sets no SWIFT_OBJC_BRIDGING_HEADER. Add a bridging header to the app target and re-run prebuild.", + ); + } + + return relativePath; +} + +/** + * Adds `#import ` to the bridging header so the Swift + * `AppDelegate` can call `CodePush.bundleURL()`. + * + * CodePush is an Objective-C pod with no module map, so Swift cannot `import` + * it. The bridging header is the only way in, and Expo generates an empty one + * from SDK 53 on. + * + * Objective-C app delegates import CodePush directly and skip this. + */ +export const withIosBridgingHeaderDependency: ConfigPlugin = ( + config, +) => { + return withDangerousMod(config, [ + "ios", + (dangerousConfig) => { + const { projectRoot, platformProjectRoot } = dangerousConfig.modRequest; + + if (IOSConfig.Paths.getAppDelegate(projectRoot).language !== "swift") { + return dangerousConfig; + } + + const pbxprojPath = IOSConfig.Paths.getPBXProjectPath(projectRoot); + const headerPath = path.join( + platformProjectRoot, + findBridgingHeaderPath(fs.readFileSync(pbxprojPath, "utf8")), + ); + + fs.writeFileSync( + headerPath, + applyBridgingHeader(fs.readFileSync(headerPath, "utf8")), + ); + + return dangerousConfig; + }, + ]); +};