From 2c3cd5d12469cbdba142e6201fcf008915214384 Mon Sep 17 00:00:00 2001 From: Siddarth Kumar Date: Mon, 28 Sep 2026 13:32:53 +0530 Subject: [PATCH] ios: fix crash caused by missing UIScene Lifecyle on iOS 27 --- .../ios/syncup.xcodeproj/project.pbxproj | 4 + mobile-app/ios/syncup/AppDelegate.swift | 19 ++-- mobile-app/ios/syncup/Info.plist | 23 +++++ mobile-app/ios/syncup/SceneDelegate.swift | 93 +++++++++++++++++++ 4 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 mobile-app/ios/syncup/SceneDelegate.swift diff --git a/mobile-app/ios/syncup.xcodeproj/project.pbxproj b/mobile-app/ios/syncup.xcodeproj/project.pbxproj index d6b1765..800cadb 100644 --- a/mobile-app/ios/syncup.xcodeproj/project.pbxproj +++ b/mobile-app/ios/syncup.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ G0S1234567890ABC12345678 /* GoServerBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = G0S1234567890ABC12345679 /* GoServerBridge.mm */; }; KA01234567890ABC12345002 /* KeepAliveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = KA01234567890ABC12345001 /* KeepAliveManager.swift */; }; QL01234567890ABC12345002 /* QuickLookPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = QL01234567890ABC12345001 /* QuickLookPresenter.swift */; }; + SD01234567890ABC12345002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = SD01234567890ABC12345001 /* SceneDelegate.swift */; }; SF01234567890ABC12345002 /* ScopedFolderStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = SF01234567890ABC12345001 /* ScopedFolderStore.swift */; }; /* End PBXBuildFile section */ @@ -48,6 +49,7 @@ G0S1234567890ABC12345680 /* GoServerBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = GoServerBridge.h; path = syncup/GoServerBridge.h; sourceTree = ""; }; KA01234567890ABC12345001 /* KeepAliveManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepAliveManager.swift; path = syncup/KeepAliveManager.swift; sourceTree = ""; }; QL01234567890ABC12345001 /* QuickLookPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = QuickLookPresenter.swift; path = syncup/QuickLookPresenter.swift; sourceTree = ""; }; + SD01234567890ABC12345001 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = syncup/SceneDelegate.swift; sourceTree = ""; }; SF01234567890ABC12345001 /* ScopedFolderStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScopedFolderStore.swift; path = syncup/ScopedFolderStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -67,6 +69,7 @@ isa = PBXGroup; children = ( F11748412D0307B40044C1D9 /* AppDelegate.swift */, + SD01234567890ABC12345001 /* SceneDelegate.swift */, BM01234567890ABC12345001 /* BackgroundManager.swift */, BM01234567890ABC12345003 /* BackgroundErrorNotifier.swift */, BM01234567890ABC12345005 /* AppShortcuts.swift */, @@ -367,6 +370,7 @@ buildActionMask = 2147483647; files = ( F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, + SD01234567890ABC12345002 /* SceneDelegate.swift in Sources */, BM01234567890ABC12345002 /* BackgroundManager.swift in Sources */, BM01234567890ABC12345004 /* BackgroundErrorNotifier.swift in Sources */, BM01234567890ABC12345006 /* AppShortcuts.swift in Sources */, diff --git a/mobile-app/ios/syncup/AppDelegate.swift b/mobile-app/ios/syncup/AppDelegate.swift index f0dcf8a..fdd2c6e 100644 --- a/mobile-app/ios/syncup/AppDelegate.swift +++ b/mobile-app/ios/syncup/AppDelegate.swift @@ -6,10 +6,14 @@ import UserNotifications @UIApplicationMain public class AppDelegate: ExpoAppDelegate { + // Owned by SceneDelegate under the UIScene lifecycle; kept here because + // UIKit and some libraries still read `delegate.window`. var window: UIWindow? var reactNativeDelegate: ExpoReactNativeFactoryDelegate? var reactNativeFactory: RCTReactNativeFactory? + /// Retained so SceneDelegate can hand them to startReactNative when the scene connects. + var launchOptions: [UIApplication.LaunchOptionsKey: Any]? public override func application( _ application: UIApplication, @@ -44,18 +48,19 @@ public class AppDelegate: ExpoAppDelegate { reactNativeDelegate = delegate reactNativeFactory = factory + self.launchOptions = launchOptions -#if os(iOS) || os(tvOS) - window = UIWindow(frame: UIScreen.main.bounds) - factory.startReactNative( - withModuleName: "main", - in: window, - launchOptions: launchOptions) -#endif + // Window creation + startReactNative live in SceneDelegate.scene(_:willConnectTo:options:). + // iOS 27 refuses to launch SDK-27-linked apps that build the window here without + // adopting UIScene (see UIApplicationSceneManifest in Info.plist). return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + // NOTE: with a scene manifest present UIKit no longer calls the app-level + // background/foreground/active callbacks; SceneDelegate forwards its scene + // callbacks here so the logic (and Expo subscriber fan-out via super) is unchanged. + public override func applicationDidEnterBackground(_ application: UIApplication) { super.applicationDidEnterBackground(application) // re-submit request on every backgrounding; iOS drops stale ones. diff --git a/mobile-app/ios/syncup/Info.plist b/mobile-app/ios/syncup/Info.plist index 9cd4073..06a8b3c 100644 --- a/mobile-app/ios/syncup/Info.plist +++ b/mobile-app/ios/syncup/Info.plist @@ -73,6 +73,29 @@ SyncUp discovers peer devices on your local network to sync files directly without a server. RCTNewArchEnabled + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + UIBackgroundModes fetch diff --git a/mobile-app/ios/syncup/SceneDelegate.swift b/mobile-app/ios/syncup/SceneDelegate.swift new file mode 100644 index 0000000..c9f11a7 --- /dev/null +++ b/mobile-app/ios/syncup/SceneDelegate.swift @@ -0,0 +1,93 @@ +import Expo +import React +import UIKit + +/// UIScene lifecycle adoption. +/// +/// Apps linked against the iOS 27 SDK must adopt the UIScene lifecycle. On iOS 27 +/// the legacy UIApplicationDelegate-owned window path traps at launch inside +/// `_UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption` (EXC_BREAKPOINT), +/// so the app never gets past scene creation. +/// +/// The scene owns the UIWindow and starts React Native into it. App-level +/// concerns (Go daemon, BGTaskScheduler registration, notifications, App +/// Shortcuts) stay in AppDelegate.didFinishLaunching, which still runs before +/// the first scene connects. Foreground/background transitions are forwarded to +/// the AppDelegate handlers so ExpoAppDelegate subscribers keep receiving them; +/// UIKit stops calling those app-level methods once a scene manifest exists. +public class SceneDelegate: UIResponder, UIWindowSceneDelegate { + public var window: UIWindow? + + private var appDelegate: AppDelegate? { + UIApplication.shared.delegate as? AppDelegate + } + + public func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + guard let appDelegate = appDelegate, let factory = appDelegate.reactNativeFactory else { + NSLog("SceneDelegate: AppDelegate or ReactNativeFactory missing; cannot start React Native") + return + } + + let window = UIWindow(windowScene: windowScene) + self.window = window + appDelegate.window = window + + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: appDelegate.launchOptions) + + // Deep links / user activities that arrived with the cold launch are no + // longer delivered via application(_:open:options:) under the scene lifecycle. + if !connectionOptions.urlContexts.isEmpty { + self.scene(scene, openURLContexts: connectionOptions.urlContexts) + } + for activity in connectionOptions.userActivities { + self.scene(scene, continue: activity) + } + } + + public func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let appDelegate = appDelegate else { return } + for context in URLContexts { + 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) + } + } + + public func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard let appDelegate = appDelegate else { return } + _ = appDelegate.application(UIApplication.shared, continue: userActivity) { _ in } + } + + // MARK: - Lifecycle forwarding + + public func sceneDidBecomeActive(_ scene: UIScene) { + appDelegate?.applicationDidBecomeActive(UIApplication.shared) + } + + public func sceneWillResignActive(_ scene: UIScene) { + appDelegate?.applicationWillResignActive(UIApplication.shared) + } + + public func sceneDidEnterBackground(_ scene: UIScene) { + appDelegate?.applicationDidEnterBackground(UIApplication.shared) + } + + public func sceneWillEnterForeground(_ scene: UIScene) { + appDelegate?.applicationWillEnterForeground(UIApplication.shared) + } +}