From 35ce9c998343085accec6c7021685415f9fc00f0 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 11:36:28 -0400 Subject: [PATCH 1/3] fix(ios): adopt the UIScene life cycle so Xcode 27 builds launch Apps built with the iOS 27 SDK trap at launch in _UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption unless they use scenes. SceneDelegate builds on Expo's ExpoAppSceneDelegate, which forwards lifecycle, URL and user-activity events to the AppDelegate overrides, and moves React Native's root view controller into a KeyboardWindow so hardware enter/shift-enter keep working. --- shared/ios/Keybase.xcodeproj/project.pbxproj | 4 ++ shared/ios/Keybase/AppDelegate.swift | 41 +++++++++----------- shared/ios/Keybase/Info.plist | 17 ++++++++ shared/ios/Keybase/SceneDelegate.swift | 31 +++++++++++++++ 4 files changed, 71 insertions(+), 22 deletions(-) create mode 100644 shared/ios/Keybase/SceneDelegate.swift diff --git a/shared/ios/Keybase.xcodeproj/project.pbxproj b/shared/ios/Keybase.xcodeproj/project.pbxproj index 7750bbcd19d1..04efe02c9f9d 100644 --- a/shared/ios/Keybase.xcodeproj/project.pbxproj +++ b/shared/ios/Keybase.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ DBDF89F62DF7779900EA18C2 /* Pusher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF89F52DF7779900EA18C2 /* Pusher.swift */; }; DBF123462DF1234500A12345 /* ShareIntentDonatorImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */; }; DBPERF022600000002 /* PerfFPSMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBPERF022600000001 /* PerfFPSMonitor.swift */; }; + DBSCENE00270000000000002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBSCENE00270000000000001 /* SceneDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -120,6 +121,7 @@ DBDF89F52DF7779900EA18C2 /* Pusher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pusher.swift; sourceTree = ""; }; DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareIntentDonatorImpl.swift; sourceTree = ""; }; DBPERF022600000001 /* PerfFPSMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfFPSMonitor.swift; sourceTree = ""; }; + DBSCENE00270000000000001 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; F68DC40B579A1F9AC0F34950 /* Pods_KeybaseShare.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_KeybaseShare.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -270,6 +272,7 @@ children = ( 005BF9961BB9C6B000BD8953 /* Keybase.entitlements */, DBB8CC202DF336C200D43215 /* AppDelegate.swift */, + DBSCENE00270000000000001 /* SceneDelegate.swift */, DBDCF3081B8D03DD00BA95D8 /* Images.xcassets */, DBDCF3091B8D03DD00BA95D8 /* Info.plist */, DB07050422E21B8B002F273D /* KeepThisFile.swift */, @@ -592,6 +595,7 @@ buildActionMask = 2147483647; files = ( DBB8CC212DF336C200D43215 /* AppDelegate.swift in Sources */, + DBSCENE00270000000000002 /* SceneDelegate.swift in Sources */, DB07050522E21B8B002F273D /* KeepThisFile.swift in Sources */, DBDF89F62DF7779900EA18C2 /* Pusher.swift in Sources */, DBF123462DF1234500A12345 /* ShareIntentDonatorImpl.swift in Sources */, diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 53ae0007c74b..d5265b1b1c8b 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -36,11 +36,12 @@ class KeyboardWindow: UIWindow { } @main -class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInteractionDelegate { +class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotificationCenterDelegate, UIDropInteractionDelegate { var window: UIWindow? var reactNativeDelegate: ExpoReactNativeFactoryDelegate? var reactNativeFactory: RCTReactNativeFactory? + var reactNativeFactoryModuleName: String { "Keybase" } var resignImageView: UIImageView? var fsPaths: [String: String] = [:] @@ -89,15 +90,6 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte reactNativeDelegate = delegate reactNativeFactory = factory -#if os(iOS) || os(tvOS) - let screenBounds = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.screen.bounds ?? UIScreen.main.bounds - window = KeyboardWindow(frame: screenBounds) - factory.startReactNative( - withModuleName: "Keybase", - in: window, - launchOptions: launchOptions) -#endif - self.writeStartupTimingLog("After RN init") self.closeStartupLogFile() @@ -106,10 +98,7 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte // Start FPS monitoring if launched with -PERF_FPS_MONITOR PerfFPSMonitor.startIfEnabled() - if let rootView = self.window?.rootViewController?.view { - self.addDrop(rootView) - self.didLaunchSetupAfter(application: application, rootView: rootView) - } + self.didLaunchSetupAfter(application: application) return true } @@ -249,13 +238,26 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte UNUserNotificationCenter.current().delegate = self } - func didLaunchSetupAfter(application: UIApplication, rootView: UIView) { + // BGTaskScheduler.register must run before didFinishLaunching returns, so this + // can't wait for the scene to connect. + func didLaunchSetupAfter(application: UIApplication) { notifyAppState(application) + BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.keybase.app.refresh", using: nil) { task in + self.handleAppRefresh(task: task as! BGAppRefreshTask) + } + scheduleAppRefresh() + } + + // Called by SceneDelegate once the window exists and React Native has started in it. + func didStartReactNative(in window: UIWindow) { + guard let rootView = window.rootViewController?.view else { return } + addDrop(rootView) + rootView.backgroundColor = .systemBackground // Snapshot resizing workaround for iPad - let screenBounds = self.window?.windowScene?.screen.bounds ?? UIScreen.main.bounds + let screenBounds = window.windowScene?.screen.bounds ?? window.bounds var dim = screenBounds.width if screenBounds.height > dim { dim = screenBounds.height @@ -266,12 +268,7 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte self.resignImageView?.alpha = 0 self.resignImageView?.backgroundColor = rootView.backgroundColor self.resignImageView?.image = UIImage(named: "LaunchImage") - if let view = self.resignImageView { self.window?.addSubview(view) } - - BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.keybase.app.refresh", using: nil) { task in - self.handleAppRefresh(task: task as! BGAppRefreshTask) - } - scheduleAppRefresh() + if let view = self.resignImageView { window.addSubview(view) } } func addDrop(_ rootView: UIView) { diff --git a/shared/ios/Keybase/Info.plist b/shared/ios/Keybase/Info.plist index 4cf9da2c060b..2f56065bc0e3 100644 --- a/shared/ios/Keybase/Info.plist +++ b/shared/ios/Keybase/Info.plist @@ -87,6 +87,23 @@ SourceCodePro-Semibold.ttf kb.ttf + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + BGTaskSchedulerPermittedIdentifiers com.keybase.app.refresh diff --git a/shared/ios/Keybase/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift new file mode 100644 index 000000000000..f9a93d0b2091 --- /dev/null +++ b/shared/ios/Keybase/SceneDelegate.swift @@ -0,0 +1,31 @@ +internal import Expo +import UIKit + +class SceneDelegate: ExpoAppSceneDelegate { + override func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + super.scene(scene, willConnectTo: session, options: connectionOptions) + + guard let windowScene = scene as? UIWindowScene, + let expoWindow = self.window, + let rootViewController = expoWindow.rootViewController, + let appDelegate = UIApplication.shared.delegate as? AppDelegate + else { return } + + // ExpoAppSceneDelegate always creates a plain UIWindow, but hardware enter / + // shift-enter in the chat input needs KeyboardWindow. Move React Native's root + // view controller over before anything has rendered. + expoWindow.rootViewController = nil + expoWindow.isHidden = true + let window = KeyboardWindow(windowScene: windowScene) + window.rootViewController = rootViewController + window.makeKeyAndVisible() + self.window = window + appDelegate.window = window + + appDelegate.didStartReactNative(in: window) + } +} From 8c2f38ffaccbd25f708aa08e77744f2e46d1a4c2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 11:41:32 -0400 Subject: [PATCH 2/3] fix(ios): report foreground on become-active so the http server starts under scenes sceneDidBecomeActive is forwarded to applicationDidBecomeActive while applicationState still reads .inactive, so notifyAppState told Go INACTIVE and the local http server stayed stopped, leaving images blank. --- shared/ios/Keybase/AppDelegate.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index d5265b1b1c8b..8182d5c6f75a 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -466,7 +466,9 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi log.info("applicationDidBecomeActive: hiding keyz screen.") hideCover() log.info("applicationDidBecomeActive: notifying service.") - notifyAppState(application) + // Forwarded from sceneDidBecomeActive, where applicationState still reads + // .inactive; notifyAppState would stop the http server. + Keybasego.KeybaseSetAppStateForeground() // Re-emit a notification the user tapped while React Native wasn't ready yet. KbEmitStoredNotificationOnBecomeActive() From ca5c47d1090cdaf4378783d8dfc763c333e39b33 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 11:52:21 -0400 Subject: [PATCH 3/3] refactor(ios): follow Expo's scene template Use the stock @objc(SceneDelegate) ExpoAppSceneDelegate with only a post-connect hook for root view setup. Drop KeyboardWindow in favor of handling hardware enter/shift-enter on the app delegate at the end of the responder chain, and drop the manual RCTLinkingManager overrides that Expo's scene forwarder already covers. --- shared/ios/Keybase/AppDelegate.swift | 64 +++++++++----------------- shared/ios/Keybase/SceneDelegate.swift | 17 +------ 2 files changed, 25 insertions(+), 56 deletions(-) diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 8182d5c6f75a..04648fc15bdd 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -11,30 +11,6 @@ import os private let log = Logger(subsystem: "com.keybase.app", category: "delegate") -class KeyboardWindow: UIWindow { - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let key = presses.first?.key else { - super.pressesBegan(presses, with: event) - return - } - - if key.keyCode == .keyboardReturnOrEnter { - if key.modifierFlags.contains(.shift) { - NotificationCenter.default.post(name: NSNotification.Name("hardwareKeyPressed"), - object: nil, - userInfo: ["pressedKey": "shift-enter"]) - } else { - NotificationCenter.default.post(name: NSNotification.Name("hardwareKeyPressed"), - object: nil, - userInfo: ["pressedKey": "enter"]) - } - return - } - - super.pressesBegan(presses, with: event) - } -} - @main class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotificationCenterDelegate, UIDropInteractionDelegate { var window: UIWindow? @@ -103,23 +79,28 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi return true } - // Linking API - override func application( - _ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { - return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) - } + // Hardware keyboard enter/shift-enter reaches the app delegate at the end of the + // responder chain (window -> scene -> application -> delegate). + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let key = presses.first?.key else { + super.pressesBegan(presses, with: event) + return + } - // Universal Links - override func application( - _ application: UIApplication, - continue userActivity: NSUserActivity, - restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void - ) -> Bool { - let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) - return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + if key.keyCode == .keyboardReturnOrEnter { + if key.modifierFlags.contains(.shift) { + NotificationCenter.default.post(name: NSNotification.Name("hardwareKeyPressed"), + object: nil, + userInfo: ["pressedKey": "shift-enter"]) + } else { + NotificationCenter.default.post(name: NSNotification.Name("hardwareKeyPressed"), + object: nil, + userInfo: ["pressedKey": "enter"]) + } + return + } + + super.pressesBegan(presses, with: event) } /////// KB specific @@ -292,7 +273,8 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi self.iph = ItemProviderHelper(forShare: false, withItems: [items]) { [weak self] in guard let self else { return } let url = URL(string: "keybase://incoming-share")! - _ = self.application(UIApplication.shared, open: url, options: [:]) + let app = UIApplication.shared + _ = self.application(app, open: url, options: [:]) || RCTLinkingManager.application(app, open: url, options: [:]) self.iph = nil } self.iph?.startProcessing() diff --git a/shared/ios/Keybase/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift index f9a93d0b2091..17be345c2006 100644 --- a/shared/ios/Keybase/SceneDelegate.swift +++ b/shared/ios/Keybase/SceneDelegate.swift @@ -1,6 +1,7 @@ internal import Expo import UIKit +@objc(SceneDelegate) class SceneDelegate: ExpoAppSceneDelegate { override func scene( _ scene: UIScene, @@ -9,23 +10,9 @@ class SceneDelegate: ExpoAppSceneDelegate { ) { super.scene(scene, willConnectTo: session, options: connectionOptions) - guard let windowScene = scene as? UIWindowScene, - let expoWindow = self.window, - let rootViewController = expoWindow.rootViewController, + guard let window = self.window, let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } - - // ExpoAppSceneDelegate always creates a plain UIWindow, but hardware enter / - // shift-enter in the chat input needs KeyboardWindow. Move React Native's root - // view controller over before anything has rendered. - expoWindow.rootViewController = nil - expoWindow.isHidden = true - let window = KeyboardWindow(windowScene: windowScene) - window.rootViewController = rootViewController - window.makeKeyAndVisible() - self.window = window - appDelegate.window = window - appDelegate.didStartReactNative(in: window) } }