diff --git a/packages/app-check/plugin/__tests__/__snapshots__/iosPlugin.test.ts.snap b/packages/app-check/plugin/__tests__/__snapshots__/iosPlugin.test.ts.snap index cf6d3ddf6e..df86da0dde 100644 --- a/packages/app-check/plugin/__tests__/__snapshots__/iosPlugin.test.ts.snap +++ b/packages/app-check/plugin/__tests__/__snapshots__/iosPlugin.test.ts.snap @@ -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 + } +} +" +`; diff --git a/packages/app-check/plugin/__tests__/fixtures/AppDelegate_sdk58.swift b/packages/app-check/plugin/__tests__/fixtures/AppDelegate_sdk58.swift new file mode 100644 index 0000000000..34fd928b70 --- /dev/null +++ b/packages/app-check/plugin/__tests__/fixtures/AppDelegate_sdk58.swift @@ -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 + } +} diff --git a/packages/app-check/plugin/__tests__/iosPlugin.test.ts b/packages/app-check/plugin/__tests__/iosPlugin.test.ts index 61b1bc227e..ec4aeb6596 100644 --- a/packages/app-check/plugin/__tests__/iosPlugin.test.ts +++ b/packages/app-check/plugin/__tests__/iosPlugin.test.ts @@ -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', diff --git a/packages/app-check/plugin/src/ios/appDelegate.ts b/packages/app-check/plugin/src/ios/appDelegate.ts index 254dca233f..72ceb81b3f 100644 --- a/packages/app-check/plugin/src/ios/appDelegate.ts +++ b/packages/app-check/plugin/src/ios/appDelegate.ts @@ -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 @@ -149,12 +168,23 @@ 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 { @@ -162,8 +192,8 @@ export function modifySwiftAppDelegate(contents: string): string { tag: '@react-native-firebase/app-check', src: contents, newSrc: methodInvocationBlock, - anchor: methodInvocationLineMatcher, - offset: 0, + anchor, + offset, comment: '//', }).contents; } catch (_e) { diff --git a/packages/app/plugin/__tests__/__snapshots__/iosPlugin.test.ts.snap b/packages/app/plugin/__tests__/__snapshots__/iosPlugin.test.ts.snap index 11443b0ab1..8a7b0acec9 100644 --- a/packages/app/plugin/__tests__/__snapshots__/iosPlugin.test.ts.snap +++ b/packages/app/plugin/__tests__/__snapshots__/iosPlugin.test.ts.snap @@ -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 + } +} +" +`; diff --git a/packages/app/plugin/__tests__/fixtures/AppDelegate_sdk58.swift b/packages/app/plugin/__tests__/fixtures/AppDelegate_sdk58.swift new file mode 100644 index 0000000000..34fd928b70 --- /dev/null +++ b/packages/app/plugin/__tests__/fixtures/AppDelegate_sdk58.swift @@ -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 + } +} diff --git a/packages/app/plugin/__tests__/iosPlugin.test.ts b/packages/app/plugin/__tests__/iosPlugin.test.ts index fdfd111b3d..256308009f 100644 --- a/packages/app/plugin/__tests__/iosPlugin.test.ts +++ b/packages/app/plugin/__tests__/iosPlugin.test.ts @@ -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'; @@ -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 '; const doubleImport = singleImport + '\n#import '; diff --git a/packages/app/plugin/src/ios/appDelegate.ts b/packages/app/plugin/src/ios/appDelegate.ts index 3a42192a9c..13cb663b7f 100644 --- a/packages/app/plugin/src/ios/appDelegate.ts +++ b/packages/app/plugin/src/ios/appDelegate.ts @@ -98,6 +98,25 @@ export function modifyObjcAppDelegate(contents: string): string { } } +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 modifySwiftAppDelegate(contents: string): string { const methodInvocationBlock = `FirebaseApp.configure()`; const methodInvocationLineMatcher = @@ -108,8 +127,9 @@ export function modifySwiftAppDelegate(contents: string): string { contents, 'import FirebaseCore', /^[ \t]*import\s+FirebaseCore[ \t]*$/m, - /^[ \t]*import\s+Expo[ \t]*$/m, - /^[ \t]*import\b[^\r\n]*$/m, + // The SDK 58 template writes `internal import Expo`, so allow an access-level modifier. + /^[ \t]*(?:\w+\s+)?import\s+Expo[ \t]*$/m, + /^[ \t]*(?:\w+\s+)?import\b[^\r\n]*$/m, ); // To avoid potential issues with existing changes from older plugin versions @@ -117,7 +137,19 @@ export function modifySwiftAppDelegate(contents: string): string { return contents; } - if (!methodInvocationLineMatcher.test(contents)) { + if (methodInvocationLineMatcher.test(contents)) { + return mergeContents({ + tag: '@react-native-firebase/app-didFinishLaunchingWithOptions', + src: contents, + newSrc: methodInvocationBlock, + anchor: methodInvocationLineMatcher, + offset: 0, // new line will be inserted right above matched anchor + comment: '//', + }).contents; + } + + const bodyOffset = getSwiftMethodBodyOffset(contents, swiftDidFinishLaunchingAnchor); + if (bodyOffset === null) { WarningAggregator.addWarningIOS( '@react-native-firebase/app', 'Unable to determine correct Firebase insertion point in AppDelegate.swift. Skipping Firebase addition.', @@ -125,13 +157,12 @@ export function modifySwiftAppDelegate(contents: string): string { return contents; } - // Add invocation return mergeContents({ tag: '@react-native-firebase/app-didFinishLaunchingWithOptions', src: contents, newSrc: methodInvocationBlock, - anchor: methodInvocationLineMatcher, - offset: 0, // new line will be inserted right above matched anchor + anchor: swiftDidFinishLaunchingAnchor, + offset: bodyOffset, // new lines will be inserted at the top of the method body comment: '//', }).contents; } diff --git a/packages/auth/plugin/__tests__/__snapshots__/iosPlugin_sceneDelegate.test.ts.snap b/packages/auth/plugin/__tests__/__snapshots__/iosPlugin_sceneDelegate.test.ts.snap new file mode 100644 index 0000000000..47ebc8274a --- /dev/null +++ b/packages/auth/plugin/__tests__/__snapshots__/iosPlugin_sceneDelegate.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Config Plugin iOS Tests - sceneDelegate patching inserts the openURLContexts override into the SDK 58 SceneDelegate 1`] = ` +"internal import Expo + +@objc(SceneDelegate) +class SceneDelegate: ExpoAppSceneDelegate { +// @generated begin @react-native-firebase/auth-openURL - expo prebuild (DO NOT MODIFY) + override func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + // invocations for Firebase Auth are handled elsewhere and should not be forwarded to Expo Router + let forwardedContexts = URLContexts.filter { $0.url.host?.lowercased() != "firebaseauth" } + guard !forwardedContexts.isEmpty else { + return + } + super.scene(scene, openURLContexts: forwardedContexts) + } +// @generated end @react-native-firebase/auth-openURL + // Extension point for config plugins. +} +" +`; diff --git a/packages/auth/plugin/__tests__/fixtures/AppDelegate_sdk58.swift b/packages/auth/plugin/__tests__/fixtures/AppDelegate_sdk58.swift new file mode 100644 index 0000000000..34fd928b70 --- /dev/null +++ b/packages/auth/plugin/__tests__/fixtures/AppDelegate_sdk58.swift @@ -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 + } +} diff --git a/packages/auth/plugin/__tests__/fixtures/SceneDelegate_sdk58.swift b/packages/auth/plugin/__tests__/fixtures/SceneDelegate_sdk58.swift new file mode 100644 index 0000000000..788dde8f46 --- /dev/null +++ b/packages/auth/plugin/__tests__/fixtures/SceneDelegate_sdk58.swift @@ -0,0 +1,6 @@ +internal import Expo + +@objc(SceneDelegate) +class SceneDelegate: ExpoAppSceneDelegate { + // Extension point for config plugins. +} diff --git a/packages/auth/plugin/__tests__/iosPlugin_sceneDelegate.test.ts b/packages/auth/plugin/__tests__/iosPlugin_sceneDelegate.test.ts new file mode 100644 index 0000000000..d3f5908976 --- /dev/null +++ b/packages/auth/plugin/__tests__/iosPlugin_sceneDelegate.test.ts @@ -0,0 +1,223 @@ +import fs from 'fs/promises'; +import fsSync from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { WarningAggregator } from '@expo/config-plugins'; +import type { AppDelegateProjectFile } from '@expo/config-plugins/build/ios/Paths'; + +import { modifySceneDelegate, generatedTag } from '../src/ios/sceneDelegate'; +import { findSceneDelegateFile, infoPlistDeclaresSceneManifest } from '../src/ios/sceneLifecycle'; +import { withOpenUrlFixForAppDelegate } from '../src/ios/openUrlFix'; + +const SCENE_MANIFEST_PLIST = ` + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + +`; + +describe('Config Plugin iOS Tests - sceneDelegate', () => { + let tmpDir: string; + + beforeEach(async () => { + jest.resetAllMocks(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rnfb-scene-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + describe('detection', () => { + it('finds SceneDelegate.swift nested under the project directory', async () => { + const projectDir = path.join(tmpDir, 'HelloWorld'); + await fs.mkdir(projectDir, { recursive: true }); + const scenePath = path.join(projectDir, 'SceneDelegate.swift'); + await fs.writeFile(scenePath, 'class SceneDelegate: ExpoAppSceneDelegate {\n}\n'); + + expect(findSceneDelegateFile(tmpDir)).toBe(scenePath); + }); + + it('finds SceneDelegate.swift at the ios root (bare-expo layout)', async () => { + const scenePath = path.join(tmpDir, 'SceneDelegate.swift'); + await fs.writeFile(scenePath, 'class SceneDelegate: ExpoAppSceneDelegate {\n}\n'); + + expect(findSceneDelegateFile(tmpDir)).toBe(scenePath); + }); + + it('ignores Pods and build directories', async () => { + const podsDir = path.join(tmpDir, 'Pods', 'SomePod'); + await fs.mkdir(podsDir, { recursive: true }); + await fs.writeFile(path.join(podsDir, 'SceneDelegate.swift'), 'class SceneDelegate {}'); + + expect(findSceneDelegateFile(tmpDir)).toBeNull(); + }); + + it('detects the scene lifecycle from Info.plist when SceneDelegate.swift is absent', async () => { + const projectDir = path.join(tmpDir, 'HelloWorld'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(projectDir, 'Info.plist'), SCENE_MANIFEST_PLIST); + + expect(findSceneDelegateFile(tmpDir)).toBeNull(); + expect(infoPlistDeclaresSceneManifest(tmpDir)).toBe(true); + }); + + it('reports false for an app-delegate lifecycle project', async () => { + const projectDir = path.join(tmpDir, 'HelloWorld'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(projectDir, 'Info.plist'), ''); + + expect(infoPlistDeclaresSceneManifest(tmpDir)).toBe(false); + }); + + it('reports false for a missing or empty platform project root', () => { + expect(infoPlistDeclaresSceneManifest(path.join(tmpDir, 'does-not-exist'))).toBe(false); + expect(infoPlistDeclaresSceneManifest('')).toBe(false); + expect(findSceneDelegateFile('')).toBeNull(); + }); + }); + + describe('patching', () => { + it('inserts the openURLContexts override into the SDK 58 SceneDelegate', async () => { + const sceneDelegate = await fs.readFile( + path.join(__dirname, './fixtures/SceneDelegate_sdk58.swift'), + { encoding: 'utf8' }, + ); + + const result = modifySceneDelegate(sceneDelegate); + + expect(result).not.toBeNull(); + expect(result).toContain('override func scene(_ scene: UIScene, openURLContexts'); + expect(result).toContain('$0.url.host?.lowercased() != "firebaseauth"'); + expect(result).toContain('super.scene(scene, openURLContexts: forwardedContexts)'); + expect(result).toMatchSnapshot(); + }); + + it('returns null when already patched, so prebuild is idempotent', async () => { + const sceneDelegate = await fs.readFile( + path.join(__dirname, './fixtures/SceneDelegate_sdk58.swift'), + { encoding: 'utf8' }, + ); + + const once = modifySceneDelegate(sceneDelegate) as string; + expect(modifySceneDelegate(once)).toBeNull(); + expect(once.match(new RegExp(generatedTag, 'g'))).toHaveLength(2); + }); + + it('returns null for a file that is not a SceneDelegate subclass', () => { + expect(modifySceneDelegate('import Expo\n\nstruct Unrelated {}\n')).toBeNull(); + }); + }); + + describe('app delegate fix defers to the scene lifecycle', () => { + async function makeSceneProject(): Promise { + const projectDir = path.join(tmpDir, 'HelloWorld'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, 'SceneDelegate.swift'), + 'class SceneDelegate: ExpoAppSceneDelegate {\n}\n', + ); + return projectDir; + } + + function makeConfig(appDelegate: string, platformProjectRoot: string) { + return { + name: 'TestName', + slug: 'TestSlug', + plugins: ['expo-router'], + modRequest: { projectRoot: tmpDir, platformProjectRoot } as any, + modResults: { + path: path.join(platformProjectRoot, 'AppDelegate.swift'), + language: 'swift', + contents: appDelegate, + } as AppDelegateProjectFile, + modRawConfig: { name: 'TestName', slug: 'TestSlug' }, + }; + } + + it('does not warn when the AppDelegate has no openURL but the project uses scenes', async () => { + await makeSceneProject(); + const appDelegate = await fs.readFile( + path.join(__dirname, './fixtures/AppDelegate_sdk58.swift'), + { encoding: 'utf8' }, + ); + const spy = jest + .spyOn(WarningAggregator, 'addWarningIOS') + .mockImplementation(() => undefined); + + const result = withOpenUrlFixForAppDelegate({ + config: makeConfig(appDelegate, tmpDir), + props: undefined, + }); + + expect(result.modResults.contents).toBe(appDelegate); + expect(spy).not.toHaveBeenCalled(); + }); + + it('does not throw or warn when captchaOpenUrlFix is forced on and SceneDelegate.swift exists', async () => { + await makeSceneProject(); + const appDelegate = await fs.readFile( + path.join(__dirname, './fixtures/AppDelegate_sdk58.swift'), + { encoding: 'utf8' }, + ); + const spy = jest + .spyOn(WarningAggregator, 'addWarningIOS') + .mockImplementation(() => undefined); + + const result = withOpenUrlFixForAppDelegate({ + config: makeConfig(appDelegate, tmpDir), + props: { ios: { captchaOpenUrlFix: true } }, + }); + + expect(result.modResults.contents).toBe(appDelegate); + expect(spy).not.toHaveBeenCalled(); + }); + + it('warns instead of throwing when Info.plist declares scenes but SceneDelegate.swift is missing', async () => { + const projectDir = path.join(tmpDir, 'HelloWorld'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(projectDir, 'Info.plist'), SCENE_MANIFEST_PLIST); + const appDelegate = await fs.readFile( + path.join(__dirname, './fixtures/AppDelegate_sdk58.swift'), + { encoding: 'utf8' }, + ); + const spy = jest + .spyOn(WarningAggregator, 'addWarningIOS') + .mockImplementation(() => undefined); + + const result = withOpenUrlFixForAppDelegate({ + config: makeConfig(appDelegate, tmpDir), + props: { ios: { captchaOpenUrlFix: true } }, + }); + + expect(result.modResults.contents).toBe(appDelegate); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + '@react-native-firebase/auth', + expect.stringContaining('no SceneDelegate.swift was found'), + ); + }); + + it('still throws for an app-delegate lifecycle project with no openURL method', async () => { + const appDelegate = await fs.readFile( + path.join(__dirname, './fixtures/AppDelegate_noOpenURL_sdk53.swift'), + { encoding: 'utf8' }, + ); + // No SceneDelegate.swift and no scene manifest were written to tmpDir. + expect(fsSync.existsSync(path.join(tmpDir, 'HelloWorld'))).toBe(false); + + expect(() => + withOpenUrlFixForAppDelegate({ + config: makeConfig(appDelegate, tmpDir), + props: { ios: { captchaOpenUrlFix: true } }, + }), + ).toThrow("Failed to apply iOS openURL fix because no 'openURL' method was found"); + }); + }); +}); diff --git a/packages/auth/plugin/src/index.ts b/packages/auth/plugin/src/index.ts index be33c86e0a..807d755fb4 100644 --- a/packages/auth/plugin/src/index.ts +++ b/packages/auth/plugin/src/index.ts @@ -1,6 +1,10 @@ import { ConfigPlugin, withPlugins, createRunOncePlugin } from '@expo/config-plugins'; -import { withIosCaptchaUrlTypes, withIosCaptchaOpenUrlFix } from './ios'; +import { + withIosCaptchaUrlTypes, + withIosCaptchaOpenUrlFix, + withIosCaptchaSceneDelegateFix, +} from './ios'; import { PluginConfigType } from './pluginConfig'; /** @@ -10,7 +14,11 @@ const withRnFirebaseAuth: ConfigPlugin = (config, props) => { return withPlugins(config, [ // iOS [withIosCaptchaUrlTypes, props], + // The two openURL fixes are mutually exclusive and detect which applies at prebuild time: + // app-delegate life cycle patches `AppDelegate.swift`, UIScene life cycle (Expo SDK 58+) + // patches `SceneDelegate.swift`. [withIosCaptchaOpenUrlFix, props], + [withIosCaptchaSceneDelegateFix, props], ]); }; diff --git a/packages/auth/plugin/src/ios/index.ts b/packages/auth/plugin/src/ios/index.ts index d63f3a971b..0dd97dad3e 100644 --- a/packages/auth/plugin/src/ios/index.ts +++ b/packages/auth/plugin/src/ios/index.ts @@ -1,4 +1,5 @@ import { withIosCaptchaUrlTypes } from './urlTypes'; import { withIosCaptchaOpenUrlFix } from './openUrlFix'; +import { withIosCaptchaSceneDelegateFix } from './sceneDelegate'; -export { withIosCaptchaUrlTypes, withIosCaptchaOpenUrlFix }; +export { withIosCaptchaUrlTypes, withIosCaptchaOpenUrlFix, withIosCaptchaSceneDelegateFix }; diff --git a/packages/auth/plugin/src/ios/openUrlFix.ts b/packages/auth/plugin/src/ios/openUrlFix.ts index bc4c80c5c3..b80439c437 100644 --- a/packages/auth/plugin/src/ios/openUrlFix.ts +++ b/packages/auth/plugin/src/ios/openUrlFix.ts @@ -10,6 +10,7 @@ import type { AppDelegateProjectFile } from '@expo/config-plugins/build/ios/Path import type { InfoPlist } from '@expo/config-plugins/build/ios/IosConfig.types'; import { mergeContents } from '@expo/config-plugins/build/utils/generateCode'; import { PluginConfigType } from '../pluginConfig'; +import { findSceneDelegateFile, infoPlistDeclaresSceneManifest } from './sceneLifecycle'; export const withIosCaptchaOpenUrlFix: ConfigPlugin = ( config: ExpoConfig, @@ -64,6 +65,20 @@ export function withOpenUrlFixForAppDelegate({ const newContents = modifyAppDelegate(contents, language); if (newContents === null) { + // Under the UIScene life cycle (Xcode 27+) UIKit stops calling `application(_:open:options:)` and the + // template drops the method. `withIosCaptchaSceneDelegateFix` patches `SceneDelegate.swift` instead. + const platformProjectRoot = config.modRequest.platformProjectRoot; + if (findSceneDelegateFile(platformProjectRoot) !== null) { + return config; + } + if (infoPlistDeclaresSceneManifest(platformProjectRoot)) { + // Scene life cycle declared but no SceneDelegate.swift to patch: neither plugin can apply the fix. + WarningAggregator.addWarningIOS( + '@react-native-firebase/auth', + 'Skipping iOS openURL fix because Info.plist declares UIApplicationSceneManifest but no SceneDelegate.swift was found. Firebase Auth reCAPTCHA redirect URLs may be forwarded to your router.', + ); + return config; + } if (configValue === true) { throw new Error("Failed to apply iOS openURL fix because no 'openURL' method was found"); } else { diff --git a/packages/auth/plugin/src/ios/sceneDelegate.ts b/packages/auth/plugin/src/ios/sceneDelegate.ts new file mode 100644 index 0000000000..14403d2f0e --- /dev/null +++ b/packages/auth/plugin/src/ios/sceneDelegate.ts @@ -0,0 +1,93 @@ +import { ConfigPlugin, WarningAggregator, withDangerousMod } from '@expo/config-plugins'; +import type { ExpoConfig } from '@expo/config/build/Config.types'; +import fs from 'fs'; +import path from 'path'; + +import { PluginConfigType } from '../pluginConfig'; +import { shouldApplyIosOpenUrlFix } from './openUrlFix'; +import { findSceneDelegateFile } from './sceneLifecycle'; + +export const generatedTag = '@react-native-firebase/auth-openURL'; + +export const sceneDelegateOpenUrlBlock: string = `\ +// @generated begin ${generatedTag} - expo prebuild (DO NOT MODIFY) + override func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + // invocations for Firebase Auth are handled elsewhere and should not be forwarded to Expo Router + let forwardedContexts = URLContexts.filter { $0.url.host?.lowercased() != "firebaseauth" } + guard !forwardedContexts.isEmpty else { + return + } + super.scene(scene, openURLContexts: forwardedContexts) + } +// @generated end ${generatedTag}\ +`; + +// Matches the opening brace of a `SceneDelegate` class declaration so the override can be inserted +// as the first member. +const sceneDelegateClassMatcher = /class\s+SceneDelegate\s*:[^\n{]*\{\n/; + +/** + * Add an `openURLContexts` override that drops Firebase Auth reCAPTCHA redirects. + * + * Returns the modified contents, or null when the patch does not apply (already present, or the + * file is not a recognizable `SceneDelegate` subclass). + */ +export function modifySceneDelegate(contents: string): string | null { + if (contents.includes(generatedTag)) { + return null; + } + + const match = contents.match(sceneDelegateClassMatcher); + if (!match || match.index === undefined) { + return null; + } + + const insertionPoint = match.index + match[0].length; + return `${contents.slice(0, insertionPoint)}${sceneDelegateOpenUrlBlock}\n${contents.slice( + insertionPoint, + )}`; +} + +/** + * Applies the reCAPTCHA `openURL` fix to `SceneDelegate.swift` on projects that use the UIScene + * life cycle. On projects that still use the app-delegate life cycle this is a no-op, the + * `withIosCaptchaOpenUrlFix` plugin handles those. + */ +export const withIosCaptchaSceneDelegateFix: ConfigPlugin = ( + config: ExpoConfig, + props?: PluginConfigType, +) => { + if (!shouldApplyIosOpenUrlFix({ config, props })) { + return config; + } + + return withDangerousMod(config, [ + 'ios', + async config => { + const sceneDelegatePath = findSceneDelegateFile(config.modRequest.platformProjectRoot); + + if (sceneDelegatePath === null) { + return config; + } + + const contents = await fs.promises.readFile(sceneDelegatePath, 'utf-8'); + const newContents = modifySceneDelegate(contents); + + if (newContents === null) { + // Already patched is the common case and needs no warning; an unrecognized shape does. + if (!contents.includes(generatedTag)) { + WarningAggregator.addWarningIOS( + '@react-native-firebase/auth', + `Skipping iOS openURL fix because ${path.basename( + sceneDelegatePath, + )} is not a recognizable SceneDelegate subclass. Firebase Auth reCAPTCHA redirect URLs may be forwarded to your router.`, + ); + } + return config; + } + + await fs.promises.writeFile(sceneDelegatePath, newContents); + return config; + }, + ]); +}; diff --git a/packages/auth/plugin/src/ios/sceneLifecycle.ts b/packages/auth/plugin/src/ios/sceneLifecycle.ts new file mode 100644 index 0000000000..ba0db5efc3 --- /dev/null +++ b/packages/auth/plugin/src/ios/sceneLifecycle.ts @@ -0,0 +1,84 @@ +import fs from 'fs'; +import path from 'path'; + +// Directories that never contain the app's own scene delegate but are expensive to walk. +const IGNORED_DIRECTORIES = new Set(['Pods', 'build', 'node_modules', 'DerivedData', '.git']); + +/** + * Locate the app's `SceneDelegate.swift`. + * + * Expo SDK 58 writes it next to `AppDelegate.swift` (`ios//SceneDelegate.swift`), but + * `apps/bare-expo` keeps it at `ios/SceneDelegate.swift`, so search rather than assume a layout. + */ +export function findSceneDelegateFile(platformProjectRoot: string): string | null { + if (!platformProjectRoot || !fs.existsSync(platformProjectRoot)) { + return null; + } + + const queue: string[] = [platformProjectRoot]; + while (queue.length > 0) { + const directory = queue.shift() as string; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if ( + !IGNORED_DIRECTORIES.has(entry.name) && + !entry.name.endsWith('.xcodeproj') && + !entry.name.endsWith('.xcworkspace') + ) { + queue.push(entryPath); + } + } else if (entry.name === 'SceneDelegate.swift') { + return entryPath; + } + } + } + + return null; +} + +/** + * Detect a `UIApplicationSceneManifest` entry in the app's `Info.plist`. + * + * Independent of `findSceneDelegateFile`: a project can declare the scene life cycle without shipping + * a `SceneDelegate.swift` the plugin can patch, and callers need to tell the two cases apart. + */ +export function infoPlistDeclaresSceneManifest(platformProjectRoot: string): boolean { + if (!platformProjectRoot) { + return false; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(platformProjectRoot, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + if (!entry.isDirectory() || IGNORED_DIRECTORIES.has(entry.name)) { + continue; + } + const infoPlist = path.join(platformProjectRoot, entry.name, 'Info.plist'); + try { + if ( + fs.existsSync(infoPlist) && + fs.readFileSync(infoPlist, 'utf-8').includes('UIApplicationSceneManifest') + ) { + return true; + } + } catch { + continue; + } + } + + return false; +}