Skip to content
Draft
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 @@ -149,6 +149,8 @@ module.exports = {
sounds: ['./assets/sounds/involved_notification.wav'],
},
],
// Builds with the iOS 27 SDK (Xcode 27) must use the scene life cycle.
...(process.env.IOS_SCENE_LIFECYCLE === '1' ? ['./plugins/ios-scene-lifecycle'] : []),
],
extra: {
commit: COMMIT,
Expand Down
6 changes: 6 additions & 0 deletions docs/ios-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ Holder has to accept an updated agreement under Business in App Store Connect.

## What does not work on iOS yet

- **iPhone Duo (#145).** The release is built with Xcode 26, so iOS shows the
app at a reduced size there. A build with Xcode 27 needs the scene life
cycle: `IOS_SCENE_LIFECYCLE=1` at prebuild adds it
(plugins/ios-scene-lifecycle). With it the app starts on the Duo simulator
and fills the outer display; links that start the app and push are not yet
run under scenes, and nothing is laid out for the inner display.
- **Client certificates, partly (#3).** The app imports a .p12 into its own
keychain (plugins/ios-client-cert) and every request of the app answers the
server's certificate request with it. Two places can't: the notification
Expand Down
111 changes: 111 additions & 0 deletions plugins/ios-scene-lifecycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// UIScene life cycle for builds with the iOS 27 SDK (Xcode 27).
//
// The iOS 27 SDK refuses to launch an app that has not adopted scenes
// ("UIScene life cycle is required for apps built with this SDK"), and Expo
// ships a scene delegate only from SDK 57.0.23. Until the app is on such an
// SDK this plugin adopts scenes itself: it declares the scene manifest and
// moves the window and React Native's start from the app delegate into a
// scene delegate. The manifest alone is not enough: UIKit then creates the
// scene, but a window made with UIWindow(frame:) is never attached to it and
// the app stays black.
//
// After expo/expo#50179. The scene delegate is appended to AppDelegate.swift
// so the Xcode project needs no new file reference.
const { withAppDelegate, withInfoPlist } = require('expo/config-plugins');

const MARKER = '// plugins/ios-scene-lifecycle';

const WINDOW_RE = /^[ \t]*window = UIWindow\(frame: UIScreen\.main\.bounds\)\r?\n/m;
const START_RE = /^[ \t]*factory\.startReactNative\(\r?\n[\s\S]*?launchOptions: launchOptions\)\r?\n/m;
const WINDOW_PROPERTY = ' var window: UIWindow?\n';

const SCENE_DELEGATE = `
${MARKER}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene,
let appDelegate = UIApplication.shared.delegate as? AppDelegate,
let factory = appDelegate.reactNativeFactory else {
return
}
let window = UIWindow(windowScene: windowScene)
self.window = window
// React Native and Expo modules still look for the app delegate's window.
appDelegate.window = window
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: appDelegate.launchOptions)
// With scenes, a link that starts the app arrives here and
// application(_:open:options:) is never called.
open(connectionOptions.urlContexts)
for activity in connectionOptions.userActivities {
self.scene(scene, continue: activity)
}
}

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
open(URLContexts)
}

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
_ = UIApplication.shared.delegate?.application?(
UIApplication.shared, continue: userActivity, restorationHandler: { _ in })
}

// Through the app delegate, whose override hands the link to Expo's
// subscribers and to RCTLinkingManager, once.
private func open(_ contexts: Set<UIOpenURLContext>) {
for context in contexts {
_ = UIApplication.shared.delegate?.application?(UIApplication.shared, open: context.url, options: [:])
}
}
}
`;

function withSceneAppDelegate(config) {
return withAppDelegate(config, (cfg) => {
let contents = cfg.modResults.contents;
if (contents.includes(MARKER)) return cfg;
for (const [found, what] of [
[WINDOW_RE.test(contents), 'window creation'],
[START_RE.test(contents), 'startReactNative call'],
[contents.includes(WINDOW_PROPERTY), 'window property'],
]) {
if (!found) {
throw new Error(`ios-scene-lifecycle: AppDelegate.swift has no ${what}; the Expo template changed`);
}
}
contents = contents
.replace(WINDOW_RE, ' // The window and React Native start in SceneDelegate (plugins/ios-scene-lifecycle).\n self.launchOptions = launchOptions\n')
.replace(START_RE, '')
.replace(WINDOW_PROPERTY, `${WINDOW_PROPERTY} var launchOptions: [UIApplication.LaunchOptionsKey: Any]?\n`);
cfg.modResults.contents = contents + SCENE_DELEGATE;
return cfg;
});
}

function withSceneManifest(config) {
return withInfoPlist(config, (cfg) => {
cfg.modResults.UIApplicationSceneManifest = {
UIApplicationSupportsMultipleScenes: false,
UISceneConfigurations: {
UIWindowSceneSessionRoleApplication: [
{
UISceneConfigurationName: 'Default Configuration',
UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate',
},
],
},
};
return cfg;
});
}

module.exports = (config) => withSceneManifest(withSceneAppDelegate(config));
Loading