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; + }, + ]); +}; 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); }