const fs = require('node:fs') const path = require('node:path') const { IOSConfig, withAppDelegate, withDangerousMod, withInfoPlist, withXcodeProject } = require('expo/config-plugins') // Why: UIKit stops an app built with the iOS 27 SDK at launch unless it adopts the UIScene // lifecycle (TN3187), and Expo SDK 55's AppDelegate template still creates the window itself. // The iOS 27 SDK is what lets the app resize on iPhone Duo, so the window moves into a scene // delegate here. Drop this plugin once Expo's template adopts scenes (SDK 58). const MARKER = 'orca: UIScene lifecycle' const SCENE_MANIFEST = { UIApplicationSupportsMultipleScenes: false, UISceneConfigurations: { UIWindowSceneSessionRoleApplication: [ { UISceneConfigurationName: 'Default Configuration', UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate' } ] } } const FACTORY_PROPERTY = ' var reactNativeFactory: RCTReactNativeFactory?\n' const WINDOW_BLOCK = /#if os\(iOS\) \|\| os\(tvOS\)\s*\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)\s*\n\s*factory\.startReactNative\(\s*withModuleName: "main",\s*in: window,\s*launchOptions: launchOptions\)\s*\n#endif\n/ function patchAppDelegate(contents) { if (contents.includes(MARKER)) { return contents } // A changed template must fail prebuild rather than ship an app that cannot launch. if (!contents.includes(FACTORY_PROPERTY) || !WINDOW_BLOCK.test(contents)) { throw new Error( "ios-scene-lifecycle: Expo's AppDelegate template changed; update the plugin before building." ) } return contents .replace( FACTORY_PROPERTY, `${FACTORY_PROPERTY} // ${MARKER}: SceneDelegate starts React Native with these.\n var launchOptions: [UIApplication.LaunchOptionsKey: Any]?\n` ) .replace( WINDOW_BLOCK, ` // ${MARKER}: SceneDelegate creates the window.\n self.launchOptions = launchOptions\n` ) } const SCENE_DELEGATE_SWIFT = `import React import UIKit // ${MARKER}, generated by plugins/ios-scene-lifecycle.js. // Under scenes UIKit stops calling the AppDelegate URL callbacks, where RCTLinkingManager and // Expo's subscribers listen, so every URL and user activity is forwarded to them. class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? private var appDelegate: AppDelegate? { UIApplication.shared.delegate as? AppDelegate } func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { guard let windowScene = scene as? UIWindowScene, let appDelegate, let factory = appDelegate.reactNativeFactory else { return } let window = UIWindow(windowScene: windowScene) self.window = window // A scene iOS dropped in the background and reconnects keeps the running app, not a fresh one. let runningRoot = appDelegate.window?.rootViewController appDelegate.window = window let launchURL = connectionOptions.urlContexts.first // Universal links are the only activity the app handles. let launchActivity = connectionOptions.userActivities.first { $0.activityType == NSUserActivityTypeBrowsingWeb } if let runningRoot { window.rootViewController = runningRoot window.makeKeyAndVisible() } else { // A cold-start URL arrives here rather than in the launch options getInitialURL reads. var launchOptions = appDelegate.launchOptions ?? [:] if let launchURL { launchOptions[.url] = launchURL.url if let source = launchURL.options.sourceApplication { launchOptions[.sourceApplication] = source } } if let launchActivity { launchOptions[.userActivityDictionary] = [ UIApplication.LaunchOptionsKey.userActivityType.rawValue: launchActivity.activityType, "UIApplicationLaunchOptionsUserActivityKey": launchActivity, ] } factory.startReactNative(withModuleName: "main", in: window, launchOptions: launchOptions) } if let launchURL { forward(launchURL) } if let launchActivity { _ = appDelegate.application(UIApplication.shared, continue: launchActivity) { _ in } } } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { for context in URLContexts { forward(context) } } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { _ = appDelegate?.application(UIApplication.shared, continue: userActivity) { _ in } } private func forward(_ context: UIOpenURLContext) { var options: [UIApplication.OpenURLOptionsKey: Any] = [ .openInPlace: context.options.openInPlace ] if let source = context.options.sourceApplication { options[.sourceApplication] = source } if let annotation = context.options.annotation { options[.annotation] = annotation } _ = appDelegate?.application(UIApplication.shared, open: context.url, options: options) } } ` module.exports = function withIosSceneLifecycle(config) { config = withInfoPlist(config, (cfg) => { cfg.modResults.UIApplicationSceneManifest = SCENE_MANIFEST return cfg }) config = withAppDelegate(config, (cfg) => { if (cfg.modResults.language !== 'swift') { throw new Error( `ios-scene-lifecycle: expected a Swift AppDelegate, got ${cfg.modResults.language}` ) } cfg.modResults.contents = patchAppDelegate(cfg.modResults.contents) return cfg }) config = withDangerousMod(config, [ 'ios', (cfg) => { const { platformProjectRoot, projectName } = cfg.modRequest fs.writeFileSync( path.join(platformProjectRoot, projectName, 'SceneDelegate.swift'), SCENE_DELEGATE_SWIFT ) return cfg } ]) return withXcodeProject(config, (cfg) => { const filepath = `${cfg.modRequest.projectName}/SceneDelegate.swift` if (!cfg.modResults.hasFile(filepath)) { IOSConfig.XcodeUtils.addBuildSourceFileToGroup({ filepath, groupName: cfg.modRequest.projectName, project: cfg.modResults }) } return cfg }) }