Skip to content
Open
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
Expand Up @@ -481,3 +481,55 @@ exports[`Config Plugin iOS Tests works with AppDelegate.mm (RN 0.68+) 1`] = `
@end
"
`;

exports[`Config Plugin iOS Tests works with a UIScene lifecycle Swift AppDelegate (SDK 58+) 1`] = `
"internal import Expo
import React
import ReactAppDependencyProvider

@main
class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {
var window: UIWindow?

var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?

public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// @generated begin @react-native-firebase/app-check - expo prebuild (DO NOT MODIFY) sync-cf2eb2cc4ab0c44de03d7c0dddc7165fa89d986f
RNFBAppCheckModule.sharedInstance()
FirebaseApp.configure()
// @generated end @react-native-firebase/app-check
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()

reactNativeDelegate = delegate
reactNativeFactory = factory

// The window is created and React Native is started by \`SceneDelegate\` under the
// scene-based life cycle (required by the iOS 27 SDK).
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

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
}
}
"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
internal import Expo
import React
import ReactAppDependencyProvider

@main
class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {
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

// The window is created and React Native is started by `SceneDelegate` under the
// scene-based life cycle (required by the iOS 27 SDK).
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

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
}
}
42 changes: 42 additions & 0 deletions packages/app-check/plugin/__tests__/iosPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,48 @@ describe('Config Plugin iOS Tests', function () {
jest.resetAllMocks();
});

it('works with a UIScene lifecycle Swift AppDelegate (SDK 58+)', async function () {
const appDelegate = await fs.readFile(
path.join(__dirname, './fixtures/AppDelegate_sdk58.swift'),
{ encoding: 'utf8' },
);

// SDK 58 moved `startReactNative` into SceneDelegate.swift, so the previous anchor is gone.
expect(appDelegate).not.toContain('factory.startReactNative(');

const result = modifySwiftAppDelegate(appDelegate);

expect(result).toContain('RNFBAppCheckModule.sharedInstance()');
// Firebase requires the App Check provider factory before configure (AppCheck-AD-3).
expect(result.indexOf('RNFBAppCheckModule.sharedInstance()')).toBeLessThan(
result.indexOf('FirebaseApp.configure()'),
);
// Must land inside didFinishLaunchingWithOptions, before the super call returns.
expect(result.indexOf('RNFBAppCheckModule.sharedInstance()')).toBeLessThan(
result.indexOf('return super.application(application'),
);
expect(result).toMatchSnapshot();
});

it('prefers the app plugin generated marker when it is already present', async function () {
const appDelegate = await fs.readFile(
path.join(__dirname, './fixtures/AppDelegate_sdk58.swift'),
{ encoding: 'utf8' },
);
const firebaseLine =
'// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions';
const withMarker = appDelegate.replace(
'return super.application(application',
`${firebaseLine}\n return super.application(application`,
);

const result = modifySwiftAppDelegate(withMarker);

expect(result.indexOf('RNFBAppCheckModule.sharedInstance()')).toBeGreaterThan(
result.indexOf(firebaseLine),
);
});

it('tests changes made to old AppDelegate.m (SDK 42)', async function () {
const appDelegate = await fs.readFile(path.join(__dirname, './fixtures/AppDelegate_sdk42.m'), {
encoding: 'utf8',
Expand Down
46 changes: 38 additions & 8 deletions packages/app-check/plugin/src/ios/appDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ const methodInvocationLineMatcher =
const fallbackInvocationLineMatcher =
/-\s*\(BOOL\)\s*application:\s*\(UIApplication\s*\*\s*\)\s*\w+\s+didFinishLaunchingWithOptions:/g;

export const swiftDidFinishLaunchingAnchor: RegExp = /didFinishLaunchingWithOptions\s+\w+\s*:/;

export function getSwiftMethodBodyOffset(contents: string, anchor: RegExp): number | null {
const lines = contents.split('\n');
const anchorIndex = lines.findIndex(line => anchor.test(line));
if (anchorIndex === -1) {
return null;
}

const maxLookahead = Math.min(lines.length, anchorIndex + 8);
for (let index = anchorIndex; index < maxLookahead; index++) {
if (/\{\s*$/.test(lines[index])) {
return index - anchorIndex + 1;
}
}

return null;
}

export function modifyObjcAppDelegate(contents: string): string {
contents = preferQuotedAppCheckModuleImport(contents);
// Add import
Expand Down Expand Up @@ -149,21 +168,32 @@ export function modifySwiftAppDelegate(contents: string): string {

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;
// Under the UIScene life cycle (required by Xcode 27+) the app delegate was moved to `SceneDelegate.swift`
let anchor: RegExp;
let offset: number;
if (methodInvocationLineMatcher.test(contents)) {
anchor = methodInvocationLineMatcher;
offset = 0;
} else {
const bodyOffset = getSwiftMethodBodyOffset(contents, swiftDidFinishLaunchingAnchor);
if (bodyOffset === null) {
WarningAggregator.addWarningIOS(
'@react-native-firebase/app-check',
'Unable to determine correct insertion point in AppDelegate.swift. Skipping App Check addition.',
);
return contents;
}
anchor = swiftDidFinishLaunchingAnchor;
offset = bodyOffset;
}

try {
return mergeContents({
tag: '@react-native-firebase/app-check',
src: contents,
newSrc: methodInvocationBlock,
anchor: methodInvocationLineMatcher,
offset: 0,
anchor,
offset,
comment: '//',
}).contents;
} catch (_e) {
Expand Down
52 changes: 52 additions & 0 deletions packages/app/plugin/__tests__/__snapshots__/iosPlugin.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -530,3 +530,55 @@ FirebaseApp.configure()
}
}"
`;

exports[`Config Plugin iOS Tests works with a UIScene lifecycle Swift AppDelegate (SDK 58+) 1`] = `
"internal import Expo
import FirebaseCore
import React
import ReactAppDependencyProvider

@main
class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {
var window: UIWindow?

var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?

public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// @generated begin @react-native-firebase/app-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-10e8520570672fd76b2403b7e1e27f5198a6349a
FirebaseApp.configure()
// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()

reactNativeDelegate = delegate
reactNativeFactory = factory

// The window is created and React Native is started by \`SceneDelegate\` under the
// scene-based life cycle (required by the iOS 27 SDK).
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

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
}
}
"
`;
44 changes: 44 additions & 0 deletions packages/app/plugin/__tests__/fixtures/AppDelegate_sdk58.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
internal import Expo
import React
import ReactAppDependencyProvider

@main
class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {
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

// The window is created and React Native is started by `SceneDelegate` under the
// scene-based life cycle (required by the iOS 27 SDK).
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

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
}
}
62 changes: 61 additions & 1 deletion packages/app/plugin/__tests__/iosPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IOSConfig } from '@expo/config-plugins';
import { IOSConfig, WarningAggregator } from '@expo/config-plugins';
import { AppDelegateProjectFile } from '@expo/config-plugins/build/ios/Paths';
import fs from 'fs/promises';
import path from 'path';
Expand Down Expand Up @@ -93,6 +93,66 @@ describe('Config Plugin iOS Tests', function () {
expect(result).toMatchSnapshot();
});

it('works with a UIScene lifecycle Swift AppDelegate (SDK 58+)', async function () {
const appDelegate = await fs.readFile(
path.join(__dirname, './fixtures/AppDelegate_sdk58.swift'),
{
encoding: 'utf8',
},
);

// The SDK 58 template moved `startReactNative` into SceneDelegate.swift, so neither of the
// pre-existing anchors is present. Without the didFinishLaunchingWithOptions fallback the
// plugin silently skips and Firebase is never configured.
expect(appDelegate).not.toContain('factory.startReactNative(');
expect(appDelegate).not.toContain('self.moduleName');

const result = modifySwiftAppDelegate(appDelegate);

expect(result).toContain('import FirebaseCore');
// Must land inside didFinishLaunchingWithOptions: it has to run before any scene connects.
const configureIndex = result.indexOf('FirebaseApp.configure()');
const didFinishLaunchingIndex = result.indexOf('didFinishLaunchingWithOptions launchOptions');
const superCallIndex = result.indexOf('return super.application(application');
expect(configureIndex).toBeGreaterThan(didFinishLaunchingIndex);
expect(configureIndex).toBeLessThan(superCallIndex);
expect(result).toMatchSnapshot();
});

it('is idempotent on a UIScene lifecycle Swift AppDelegate', async function () {
const appDelegate = await fs.readFile(
path.join(__dirname, './fixtures/AppDelegate_sdk58.swift'),
{
encoding: 'utf8',
},
);

const once = modifySwiftAppDelegate(appDelegate);
const twice = modifySwiftAppDelegate(once);

expect(twice).toEqual(once);
expect(twice.match(/FirebaseApp\.configure\(\)/g)).toHaveLength(1);
expect(twice.match(/import FirebaseCore/g)).toHaveLength(1);
});

it('warns rather than throwing when a Swift AppDelegate has no recognizable insertion point', function () {
const appDelegate = `import UIKit

class NotAnAppDelegate {
var window: UIWindow?
}
`;
const spy = jest.spyOn(WarningAggregator, 'addWarningIOS').mockImplementation(() => undefined);

const result = modifySwiftAppDelegate(appDelegate);

expect(result).not.toContain('FirebaseApp.configure()');
expect(spy).toHaveBeenCalledWith(
'@react-native-firebase/app',
'Unable to determine correct Firebase insertion point in AppDelegate.swift. Skipping Firebase addition.',
);
});

it('does not add the firebase import multiple times', async function () {
const singleImport = '#import "AppDelegate.h"\n#import <Firebase/Firebase.h>';
const doubleImport = singleImport + '\n#import <Firebase/Firebase.h>';
Expand Down
Loading
Loading