diff --git a/app.config.js b/app.config.js index c4d28d1b..faf2f22c 100644 --- a/app.config.js +++ b/app.config.js @@ -120,6 +120,8 @@ export default { githubUrl: 'https://github.com/wyne/scorepad-react-native', owner: 'wyne', plugins: [ + './plugins/withIosSceneLifecycle', + './plugins/withPodsDeploymentTarget', ['expo-splash-screen', { backgroundColor: '#F2F2F7', dark: { backgroundColor: '#000000' }, diff --git a/package-lock.json b/package-lock.json index aafd7167..debd25a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "expo-font": "~56.0.5", "expo-haptics": "~56.0.3", "expo-image": "~56.0.10", + "expo-ios-scene-lifecycle-plugin": "github:YesterdaysLemon/expo-ios-scene-lifecycle-plugin", "expo-keep-awake": "~56.0.3", "expo-linking": "~56.0.13", "expo-screen-orientation": "~56.0.5", @@ -11206,6 +11207,17 @@ } } }, + "node_modules/expo-ios-scene-lifecycle-plugin": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/YesterdaysLemon/expo-ios-scene-lifecycle-plugin.git#dfd1786e687996061617d14bd5e1069880a85ded", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^56.0.8" + }, + "peerDependencies": { + "expo": ">=56.0.0" + } + }, "node_modules/expo-json-utils": { "version": "56.0.0", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-56.0.0.tgz", @@ -28381,6 +28393,13 @@ "sf-symbols-typescript": "^2.2.0" } }, + "expo-ios-scene-lifecycle-plugin": { + "version": "git+ssh://git@github.com/YesterdaysLemon/expo-ios-scene-lifecycle-plugin.git#dfd1786e687996061617d14bd5e1069880a85ded", + "from": "expo-ios-scene-lifecycle-plugin@github:YesterdaysLemon/expo-ios-scene-lifecycle-plugin", + "requires": { + "@expo/config-plugins": "^56.0.8" + } + }, "expo-json-utils": { "version": "56.0.0", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-56.0.0.tgz", diff --git a/package.json b/package.json index a0746196..0a93d721 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "expo-font": "~56.0.5", "expo-haptics": "~56.0.3", "expo-image": "~56.0.10", + "expo-ios-scene-lifecycle-plugin": "github:YesterdaysLemon/expo-ios-scene-lifecycle-plugin", "expo-keep-awake": "~56.0.3", "expo-linking": "~56.0.13", "expo-screen-orientation": "~56.0.5", diff --git a/patches/expo-ios-scene-lifecycle-plugin+0.1.0.patch b/patches/expo-ios-scene-lifecycle-plugin+0.1.0.patch new file mode 100644 index 00000000..a10709df --- /dev/null +++ b/patches/expo-ios-scene-lifecycle-plugin+0.1.0.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/expo-ios-scene-lifecycle-plugin/app.plugin.js b/node_modules/expo-ios-scene-lifecycle-plugin/app.plugin.js +index c3f914c..3c470de 100644 +--- a/node_modules/expo-ios-scene-lifecycle-plugin/app.plugin.js ++++ b/node_modules/expo-ios-scene-lifecycle-plugin/app.plugin.js +@@ -91,7 +91,7 @@ function patchAppDelegate(contents) { + let nextContents = contents; + + const startupBlockPattern = +- /#if os\(iOS\) \|\| os\(tvOS\)\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)\n\s*factory\.startReactNative\(\n\s*withModuleName: "main",\n\s*in: window,\n\s*launchOptions: launchOptions\)\n#endif/; ++ /#if os\(iOS\) \|\| os\(tvOS\)\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)\n\s*factory\.startReactNative\(\n\s*withModuleName: "main",\n\s*in: window,\n\s*launchOptions: launchOptions\)\n[ \t]*#endif/; + + if (!startupBlockPattern.test(nextContents)) { + throw new Error('Could not find the Expo AppDelegate React Native startup block to patch for UIScene lifecycle.'); diff --git a/plugins/withIosSceneLifecycle.js b/plugins/withIosSceneLifecycle.js new file mode 100644 index 00000000..2995d4eb --- /dev/null +++ b/plugins/withIosSceneLifecycle.js @@ -0,0 +1,156 @@ +// Local copy of expo-ios-scene-lifecycle-plugin with revised patching strategy. +// The upstream plugin matched the entire #if os(iOS) ... #endif block as one unit, +// which breaks when other plugins (Firebase, TouchVisualizer) inject code between +// `window = UIWindow(...)` and `factory.startReactNative(...)`. +// Instead, we target each line individually and wrap them in if #unavailable(iOS 13.0). +// Ref: https://github.com/expo/expo/issues/46664 +const { withAppDelegate, withInfoPlist } = require('@expo/config-plugins'); + +const sceneConfigurationMethod = ` public func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + configuration.delegateClass = SceneDelegate.self + return configuration + } +`; + +const sceneDelegateClass = `class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { + return + } + + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, + let factory = appDelegate.reactNativeFactory else { + return + } + + let nextWindow = UIWindow(windowScene: windowScene) + window = nextWindow + appDelegate.window = nextWindow + + factory.startReactNative( + withModuleName: "main", + in: nextWindow, + launchOptions: nil) + + if !connectionOptions.urlContexts.isEmpty { + self.scene(scene, openURLContexts: connectionOptions.urlContexts) + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let urlContext = URLContexts.first, + let appDelegate = UIApplication.shared.delegate as? AppDelegate else { + return + } + + var options: [UIApplication.OpenURLOptionsKey: Any] = [ + .openInPlace: urlContext.options.openInPlace, + ] + + if let sourceApplication = urlContext.options.sourceApplication { + options[.sourceApplication] = sourceApplication + } + + if let annotation = urlContext.options.annotation { + options[.annotation] = annotation + } + + _ = appDelegate.application(UIApplication.shared, open: urlContext.url, options: options) + } +} +`; + +function addInfoPlistSceneManifest(config) { + return withInfoPlist(config, (nextConfig) => { + nextConfig.modResults.UIApplicationSceneManifest = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: 'Default Configuration', + UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate', + }, + ], + }, + }; + + return nextConfig; + }); +} + +function patchAppDelegate(contents) { + if (contents.includes('class SceneDelegate: UIResponder, UIWindowSceneDelegate')) { + return contents; + } + + let nextContents = contents; + + // Wrap the window creation line — capture only horizontal whitespace (spaces/tabs) + // so the preceding newline isn't included and we don't get spurious blank lines. + const windowPattern = /([ \t]+)window = UIWindow\(frame: UIScreen\.main\.bounds\)/; + if (!windowPattern.test(nextContents)) { + throw new Error( + 'Could not find "window = UIWindow(frame: UIScreen.main.bounds)" to patch for UIScene lifecycle.' + ); + } + nextContents = nextContents.replace(windowPattern, (_, ws) => + `${ws}if #unavailable(iOS 13.0) {\n${ws} window = UIWindow(frame: UIScreen.main.bounds)\n${ws}}` + ); + + // Wrap the factory.startReactNative call — capture only horizontal whitespace. + const factoryPattern = + /([ \t]+)factory\.startReactNative\(\n[ \t]+withModuleName: "main",\n[ \t]+in: window,\n[ \t]+launchOptions: launchOptions\)/; + if (!factoryPattern.test(nextContents)) { + throw new Error( + 'Could not find "factory.startReactNative(...)" block to patch for UIScene lifecycle.' + ); + } + nextContents = nextContents.replace(factoryPattern, (_, ws) => + `${ws}if #unavailable(iOS 13.0) {\n${ws} factory.startReactNative(\n${ws} withModuleName: "main",\n${ws} in: window,\n${ws} launchOptions: launchOptions)\n${ws}}` + ); + + // Insert the scene configuration method before the Linking API section. + if (!nextContents.includes('configurationForConnecting connectingSceneSession')) { + const linkingMarker = '\n // Linking API'; + if (!nextContents.includes(linkingMarker)) { + throw new Error('Could not find the AppDelegate linking section to insert the UIScene configuration method.'); + } + nextContents = nextContents.replace(linkingMarker, `\n${sceneConfigurationMethod}\n // Linking API`); + } + + // Insert the SceneDelegate class before ReactNativeDelegate. + const reactNativeDelegateMarker = '\nclass ReactNativeDelegate: ExpoReactNativeFactoryDelegate'; + if (!nextContents.includes(reactNativeDelegateMarker)) { + throw new Error('Could not find ReactNativeDelegate to insert SceneDelegate.'); + } + + return nextContents.replace(reactNativeDelegateMarker, `\n${sceneDelegateClass}${reactNativeDelegateMarker}`); +} + +function addAppDelegateSceneLifecycle(config) { + return withAppDelegate(config, (nextConfig) => { + if (nextConfig.modResults.language !== 'swift') { + throw new Error( + `Cannot apply iOS scene lifecycle plugin to ${nextConfig.modResults.language} AppDelegate. Swift is required.` + ); + } + + nextConfig.modResults.contents = patchAppDelegate(nextConfig.modResults.contents); + return nextConfig; + }); +} + +module.exports = function withIosSceneLifecycle(config) { + return addAppDelegateSceneLifecycle(addInfoPlistSceneManifest(config)); +}; diff --git a/plugins/withPodsDeploymentTarget.js b/plugins/withPodsDeploymentTarget.js new file mode 100644 index 00000000..133711b4 --- /dev/null +++ b/plugins/withPodsDeploymentTarget.js @@ -0,0 +1,59 @@ +// Raises any CocoaPods target whose IPHONEOS_DEPLOYMENT_TARGET is below 15.0. +// Xcode 27 beta requires a minimum of 15.0 across all targets, but many third-party +// pods still ship with older minimums (9.0, 12.x, 13.x), causing archive failures. +// +// Also patches expo-modules-jsi Swift source for Swift 6.2 / Xcode 27 compatibility: +// `weak let runtime` is no longer valid in Swift 6.2 — must be `nonisolated(unsafe) weak var`. +// Ref: https://github.com/expo/expo/issues/46242 +const { withDangerousMod } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +const MIN_TARGET = '15.0'; + +const deploymentTargetFix = ` + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < ${MIN_TARGET} + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${MIN_TARGET}' + end + end + end +`; + +// Patches weak let → nonisolated(unsafe) weak var across expo-modules-jsi Swift sources. +// The xcframework build runs after pod install, reading from ${PODS_ROOT}/ExpoModulesJSI/, +// so patching here is sufficient. +const expoModulesJSIFix = ` + jsi_sources = Dir.glob(File.join(installer.sandbox.root, 'ExpoModulesJSI', '**', '*.swift')) + jsi_sources.each do |file| + content = File.read(file) + modified = content.gsub(/\\bweak let /, 'nonisolated(unsafe) weak var ') + File.write(file, modified) if modified != content + end +`; + +module.exports = function withPodsDeploymentTarget(config) { + return withDangerousMod(config, [ + 'ios', + (config) => { + const podfilePath = path.join(config.modRequest.platformProjectRoot, 'Podfile'); + let podfile = fs.readFileSync(podfilePath, 'utf8'); + + const marker = ' post_install do |installer|\n react_native_post_install('; + if (!podfile.includes(marker)) { + throw new Error( + 'withPodsDeploymentTarget: Could not find post_install block in Podfile to inject fixes.' + ); + } + + podfile = podfile.replace( + marker, + ` post_install do |installer|${deploymentTargetFix}${expoModulesJSIFix} react_native_post_install(` + ); + + fs.writeFileSync(podfilePath, podfile); + return config; + }, + ]); +};