Files
orca/mobile/plugins/ios-scene-lifecycle.js
T
a803cbead8 feat(mobile): iPhone Duo key rail with D-pad, split terminal panes and half-fold layout (#35177)
Carries over #33221 and #33210. Replaces our #34484 Duo support with the community design, on top of main's rebuilt terminal key bar (#36166).

Fixes #33200

Co-authored-by: Takeshi Matsumoto <zytakeshi520@gmail.com>
Co-authored-by: Lars Keggenhoff <lars@kghf.io>
2026-10-30 16:35:12 +00:00

182 lines
6.1 KiB
JavaScript

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<UIOpenURLContext>) {
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
})
}