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
2 changes: 2 additions & 0 deletions app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions patches/expo-ios-scene-lifecycle-plugin+0.1.0.patch
Original file line number Diff line number Diff line change
@@ -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.');
156 changes: 156 additions & 0 deletions plugins/withIosSceneLifecycle.js
Original file line number Diff line number Diff line change
@@ -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<UIOpenURLContext>) {
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));
};
59 changes: 59 additions & 0 deletions plugins/withPodsDeploymentTarget.js
Original file line number Diff line number Diff line change
@@ -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');

Check failure on line 8 in plugins/withPodsDeploymentTarget.js

View workflow job for this annotation

GitHub Actions / Lint & Test

`@expo/config-plugins` import should occur after import of `path`

Check failure on line 8 in plugins/withPodsDeploymentTarget.js

View workflow job for this annotation

GitHub Actions / Lint & Test

There should be at least one empty line between import groups

Check failure on line 8 in plugins/withPodsDeploymentTarget.js

View workflow job for this annotation

GitHub Actions / Lint & Test

`@expo/config-plugins` import should occur after import of `path`

Check failure on line 8 in plugins/withPodsDeploymentTarget.js

View workflow job for this annotation

GitHub Actions / Lint & Test

There should be at least one empty line between import groups

Check failure on line 8 in plugins/withPodsDeploymentTarget.js

View workflow job for this annotation

GitHub Actions / Test & Coverage (20.x)

`@expo/config-plugins` import should occur after import of `path`

Check failure on line 8 in plugins/withPodsDeploymentTarget.js

View workflow job for this annotation

GitHub Actions / Test & Coverage (20.x)

There should be at least one empty line between import groups
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;
},
]);
};
Loading