diff --git a/packages/app-check/plugin/__tests__/fixtures/AppDelegate_sdk53.swift b/packages/app-check/plugin/__tests__/fixtures/AppDelegate_sdk53.swift new file mode 100644 index 0000000000..5d236ba063 --- /dev/null +++ b/packages/app-check/plugin/__tests__/fixtures/AppDelegate_sdk53.swift @@ -0,0 +1,12 @@ +import Expo +import React + +@UIApplicationMain +class AppDelegate: ExpoAppDelegate { + override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Initialize the factory + let factory = ExpoReactNativeFactory(delegate: delegate) + factory.startReactNative(withModuleName: "main", in: window, launchOptions: launchOptions) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/app-check/plugin/__tests__/iosPlugin.test.ts b/packages/app-check/plugin/__tests__/iosPlugin.test.ts index d3d305d06e..aba80a28ea 100644 --- a/packages/app-check/plugin/__tests__/iosPlugin.test.ts +++ b/packages/app-check/plugin/__tests__/iosPlugin.test.ts @@ -4,7 +4,11 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import fs from 'fs/promises'; import path from 'path'; -import { modifyAppDelegateAsync, modifyObjcAppDelegate } from '../src/ios/appDelegate'; +import { + modifyAppDelegateAsync, + modifyObjcAppDelegate, + modifySwiftAppDelegate, +} from '../src/ios/appDelegate'; describe('Config Plugin iOS Tests', function () { beforeEach(function () { @@ -74,17 +78,52 @@ describe('Config Plugin iOS Tests', function () { ); }); - it("doesn't support Swift AppDelegate", async function () { - jest.spyOn(fs, 'writeFile').mockImplementation(async () => {}); + it('supports Swift AppDelegate', async function () { + // Use MockedFunction to properly type the mock + const writeFileMock = jest + .spyOn(fs, 'writeFile') + .mockImplementation(async () => {}) as jest.MockedFunction; + + const swiftContents = `import Expo +import React + +@UIApplicationMain +class AppDelegate: ExpoAppDelegate { + override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Initialize the factory + let factory = ExpoReactNativeFactory(delegate: delegate) + factory.startReactNative(withModuleName: "main", in: window, launchOptions: launchOptions) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +}`; const appDelegateFileInfo: AppDelegateProjectFile = { - path: '.', + path: '/app/ios/App/AppDelegate.swift', language: 'swift', - contents: 'some dummy content', + contents: swiftContents, }; - await expect(modifyAppDelegateAsync(appDelegateFileInfo)).rejects.toThrow(); - expect(fs.writeFile).not.toHaveBeenCalled(); + await modifyAppDelegateAsync(appDelegateFileInfo); + + // Check if writeFile was called + expect(writeFileMock).toHaveBeenCalled(); + + // Get the modified content with explicit string type assertion + const modifiedContents = writeFileMock.mock.calls[0][1] as string; + + // Verify import was added + expect(modifiedContents).toContain('import RNFBAppCheck'); + + // Verify initialization code was added + expect(modifiedContents).toContain('RNFBAppCheckModule.sharedInstance()'); + + // Verify Firebase.configure() was added + expect(modifiedContents).toContain('FirebaseApp.configure()'); + + // Verify the code was added before startReactNative (with explicit type assertion) + const codeIndex = (modifiedContents as string).indexOf('RNFBAppCheckModule.sharedInstance()'); + const startReactNativeIndex = (modifiedContents as string).indexOf('factory.startReactNative'); + expect(codeIndex).toBeLessThan(startReactNativeIndex); }); it('does not add the firebase import multiple times', async function () { @@ -104,4 +143,30 @@ describe('Config Plugin iOS Tests', function () { expect(twiceModifiedAppDelegate).toContain(singleImport); expect(twiceModifiedAppDelegate).not.toContain(doubleImport); }); + + it('does not add the swift import multiple times', async function () { + const swiftContents = `import Expo +import React + +@UIApplicationMain +class AppDelegate: ExpoAppDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + let factory = ExpoReactNativeFactory(delegate: delegate) + factory.startReactNative(withModuleName: "main", in: window, launchOptions: launchOptions) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +}`; + + const onceModifiedContents = modifySwiftAppDelegate(swiftContents); + expect(onceModifiedContents).toContain('import RNFBAppCheck'); + + // Count occurrences of the import + const importCount = (onceModifiedContents.match(/import RNFBAppCheck/g) || []).length; + expect(importCount).toBe(1); + + // Modify a second time and ensure imports aren't duplicated + const twiceModifiedContents = modifySwiftAppDelegate(onceModifiedContents); + const secondImportCount = (twiceModifiedContents.match(/import RNFBAppCheck/g) || []).length; + expect(secondImportCount).toBe(1); + }); }); diff --git a/packages/app-check/plugin/src/ios/appDelegate.ts b/packages/app-check/plugin/src/ios/appDelegate.ts index b759a7f063..f89db1093d 100644 --- a/packages/app-check/plugin/src/ios/appDelegate.ts +++ b/packages/app-check/plugin/src/ios/appDelegate.ts @@ -68,16 +68,91 @@ export function modifyObjcAppDelegate(contents: string): string { } } +export function modifySwiftAppDelegate(contents: string): string { + // Add imports for Swift + if (!contents.includes('import RNFBAppCheck')) { + // Try to add after FirebaseCore if it exists + if (contents.includes('import FirebaseCore')) { + contents = contents.replace( + /import FirebaseCore/g, + `import FirebaseCore +import RNFBAppCheck`, + ); + } else { + // Otherwise add after Expo + contents = contents.replace( + /import Expo/g, + `import Expo +import RNFBAppCheck`, + ); + } + } + + // Check if App Check code is already added to avoid duplication + if (contents.includes('RNFBAppCheckModule.sharedInstance()')) { + return contents; + } + + // Find the Firebase initialization end line to insert after + const firebaseLine = '// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions'; + + if (contents.includes(firebaseLine)) { + // Insert right after Firebase initialization + return contents.replace( + firebaseLine, + `${firebaseLine} + FirebaseApp.configure() + RNFBAppCheckModule.sharedInstance() + `, + ); + } + + // If Firebase initialization block not found, add both Firebase and App Check initialization + // This is to make sure Firebase is initialized before App Check + const methodInvocationBlock = `FirebaseApp.configure() + RNFBAppCheckModule.sharedInstance()`; + + const methodInvocationLineMatcher = /(?:factory\.startReactNative\()/; + + if (!methodInvocationLineMatcher.test(contents)) { + WarningAggregator.addWarningIOS( + '@react-native-firebase/app-check', + 'Unable to determine correct insertion point in AppDelegate.swift. Skipping App Check addition.', + ); + return contents; + } + + try { + return mergeContents({ + tag: '@react-native-firebase/app-check', + src: contents, + newSrc: methodInvocationBlock, + anchor: methodInvocationLineMatcher, + offset: 0, + comment: '//', + }).contents; + } catch (_e) { + WarningAggregator.addWarningIOS( + '@react-native-firebase/app-check', + 'Failed to insert App Check initialization code.', + ); + return contents; + } +} + export async function modifyAppDelegateAsync(appDelegateFileInfo: AppDelegateProjectFile) { const { language, path, contents } = appDelegateFileInfo; + let newContents; if (['objc', 'objcpp'].includes(language)) { - const newContents = modifyObjcAppDelegate(contents); - await fs.promises.writeFile(path, newContents); + newContents = modifyObjcAppDelegate(contents); + } else if (language === 'swift') { + newContents = modifySwiftAppDelegate(contents); } else { - // TODO: Support Swift throw new Error(`Cannot add Firebase code to AppDelegate of language "${language}"`); } + + await fs.promises.writeFile(path, newContents); } export const withFirebaseAppDelegate: ConfigPlugin = config => {