Skip to content

feat(app-check): add Swift AppDelegate support for Expo SDK53+ #8521

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
}
79 changes: 72 additions & 7 deletions packages/app-check/plugin/__tests__/iosPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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<typeof fs.writeFile>;

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 () {
Expand All @@ -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);
});
});
81 changes: 78 additions & 3 deletions packages/app-check/plugin/src/ios/appDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,91 @@
}
}

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(

Check warning on line 76 in packages/app-check/plugin/src/ios/appDelegate.ts

View check run for this annotation

Codecov / codecov/patch

packages/app-check/plugin/src/ios/appDelegate.ts#L76

Added line #L76 was not covered by tests
/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(

Check warning on line 101 in packages/app-check/plugin/src/ios/appDelegate.ts

View check run for this annotation

Codecov / codecov/patch

packages/app-check/plugin/src/ios/appDelegate.ts#L101

Added line #L101 was not covered by tests
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(

Check warning on line 118 in packages/app-check/plugin/src/ios/appDelegate.ts

View check run for this annotation

Codecov / codecov/patch

packages/app-check/plugin/src/ios/appDelegate.ts#L118

Added line #L118 was not covered by tests
'@react-native-firebase/app-check',
'Unable to determine correct insertion point in AppDelegate.swift. Skipping App Check addition.',
);
return contents;

Check warning on line 122 in packages/app-check/plugin/src/ios/appDelegate.ts

View check run for this annotation

Codecov / codecov/patch

packages/app-check/plugin/src/ios/appDelegate.ts#L122

Added line #L122 was not covered by tests
}

try {
return mergeContents({
tag: '@react-native-firebase/app-check',
src: contents,
newSrc: methodInvocationBlock,
anchor: methodInvocationLineMatcher,
offset: 0,
comment: '//',
}).contents;
} catch (_e) {
WarningAggregator.addWarningIOS(

Check warning on line 135 in packages/app-check/plugin/src/ios/appDelegate.ts

View check run for this annotation

Codecov / codecov/patch

packages/app-check/plugin/src/ios/appDelegate.ts#L135

Added line #L135 was not covered by tests
'@react-native-firebase/app-check',
'Failed to insert App Check initialization code.',
);
return contents;

Check warning on line 139 in packages/app-check/plugin/src/ios/appDelegate.ts

View check run for this annotation

Codecov / codecov/patch

packages/app-check/plugin/src/ios/appDelegate.ts#L139

Added line #L139 was not covered by tests
}
}

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 => {
Expand Down
Loading