From 2b07a3aa487e126c4556c39c0398a96427522168 Mon Sep 17 00:00:00 2001 From: Joseph Pender Date: Wed, 2 Sep 2026 13:58:31 -0500 Subject: [PATCH 1/5] Add UIScene and SwiftUI App-struct support on the source SPM layout --- .../App/App.xcodeproj/project.pbxproj | 32 ++-- ios-pods-template/App/App/App.swift | 20 +++ ios-pods-template/App/App/AppDelegate.swift | 55 +------ .../App/App/Base.lproj/Main.storyboard | 19 --- ios-pods-template/App/App/CapacitorView.swift | 10 ++ ios-pods-template/App/App/Info.plist | 18 +-- ios-pods-template/App/App/SceneDelegate.swift | 24 --- .../App/App.xcodeproj/project.pbxproj | 12 +- ios-spm-template/App/App/App.swift | 21 +++ ios-spm-template/App/App/AppDelegate.swift | 39 +---- ios-spm-template/App/App/CapacitorView.swift | 11 ++ ios-spm-template/App/App/SceneDelegate.swift | 24 --- .../Capacitor/CAPSceneDelegateProxy.swift | 149 ++++++++++++++++-- 13 files changed, 221 insertions(+), 213 deletions(-) create mode 100644 ios-pods-template/App/App/App.swift delete mode 100644 ios-pods-template/App/App/Base.lproj/Main.storyboard create mode 100644 ios-pods-template/App/App/CapacitorView.swift delete mode 100644 ios-pods-template/App/App/SceneDelegate.swift create mode 100644 ios-spm-template/App/App/App.swift create mode 100644 ios-spm-template/App/App/CapacitorView.swift delete mode 100644 ios-spm-template/App/App/SceneDelegate.swift diff --git a/ios-pods-template/App/App.xcodeproj/project.pbxproj b/ios-pods-template/App/App.xcodeproj/project.pbxproj index 026cbd4583..88d1ad2041 100644 --- a/ios-pods-template/App/App.xcodeproj/project.pbxproj +++ b/ios-pods-template/App/App.xcodeproj/project.pbxproj @@ -9,12 +9,12 @@ /* Begin PBXBuildFile section */ 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; - 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; - 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; - 9582B6852FE996820072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6842FE996800072D4E8 /* SceneDelegate.swift */; }; + 952C1C64302642D6000D0FF6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 952C1C63302642D3000D0FF6 /* AppDelegate.swift */; }; + 9582B68A2FE9ABF30072D4E8 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6892FE9ABF10072D4E8 /* App.swift */; }; + 9582B68C2FE9ACC30072D4E8 /* CapacitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B68B2FE9ACBE0072D4E8 /* CapacitorView.swift */; }; A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; /* End PBXBuildFile section */ @@ -22,13 +22,13 @@ 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; - 9582B6842FE996800072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 952C1C63302642D3000D0FF6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 9582B6892FE9ABF10072D4E8 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + 9582B68B2FE9ACBE0072D4E8 /* CapacitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapacitorView.swift; sourceTree = ""; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; @@ -75,10 +75,10 @@ 504EC3061FED79650016851F /* App */ = { isa = PBXGroup; children = ( - 9582B6842FE996800072D4E8 /* SceneDelegate.swift */, + 952C1C63302642D3000D0FF6 /* AppDelegate.swift */, + 9582B68B2FE9ACBE0072D4E8 /* CapacitorView.swift */, + 9582B6892FE9ABF10072D4E8 /* App.swift */, 50379B222058CBB4000EE86E /* capacitor.config.json */, - 504EC3071FED79650016851F /* AppDelegate.swift */, - 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, 504EC3131FED79650016851F /* Info.plist */, @@ -164,7 +164,6 @@ 50B271D11FEDC1A000F3C39B /* public in Resources */, 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, - 504EC30D1FED79650016851F /* Main.storyboard in Resources */, 2FAD9763203C412B000D30F8 /* config.xml in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -212,22 +211,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, - 9582B6852FE996820072D4E8 /* SceneDelegate.swift in Sources */, + 952C1C64302642D6000D0FF6 /* AppDelegate.swift in Sources */, + 9582B68A2FE9ABF30072D4E8 /* App.swift in Sources */, + 9582B68C2FE9ACC30072D4E8 /* CapacitorView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ - 504EC30B1FED79650016851F /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 504EC30C1FED79650016851F /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( diff --git a/ios-pods-template/App/App/App.swift b/ios-pods-template/App/App/App.swift new file mode 100644 index 0000000000..23b0a8cc18 --- /dev/null +++ b/ios-pods-template/App/App/App.swift @@ -0,0 +1,20 @@ +import SwiftUI +import Capacitor + +@main +struct CapacitorApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + CapacitorView() + .ignoresSafeArea() + .onOpenURL { url in + SceneDelegateProxy.shared.handle(openURL: url) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + SceneDelegateProxy.shared.handle(userActivity: activity) + } + } + } +} diff --git a/ios-pods-template/App/App/AppDelegate.swift b/ios-pods-template/App/App/AppDelegate.swift index 1dfbed0901..99ba4ab96e 100644 --- a/ios-pods-template/App/App/AppDelegate.swift +++ b/ios-pods-template/App/App/AppDelegate.swift @@ -1,58 +1,9 @@ +import Foundation import UIKit -import Capacitor - -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? +class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. return true } - - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } - - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - // Called when the app was launched with a url. Feel free to add additional processing here, - // but if you want the App API to support tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(app, open: url, options: options) - } - - func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - // Called when the app was launched with an activity, including Universal Links. - // Feel free to add additional processing here, but if you want the App API to support - // tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) - } - - func application(_ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions) -> UISceneConfiguration { - - let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - config.delegateClass = SceneDelegate.self - return config - } - } + diff --git a/ios-pods-template/App/App/Base.lproj/Main.storyboard b/ios-pods-template/App/App/Base.lproj/Main.storyboard deleted file mode 100644 index b44df7be8f..0000000000 --- a/ios-pods-template/App/App/Base.lproj/Main.storyboard +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/ios-pods-template/App/App/CapacitorView.swift b/ios-pods-template/App/App/CapacitorView.swift new file mode 100644 index 0000000000..12575c875b --- /dev/null +++ b/ios-pods-template/App/App/CapacitorView.swift @@ -0,0 +1,10 @@ +import SwiftUI +import Capacitor + +public struct CapacitorView: UIViewControllerRepresentable { + public func makeUIViewController(context: Context) -> CAPBridgeViewController { + CAPBridgeViewController() + } + + public func updateUIViewController(_ vc: CAPBridgeViewController, context: Context) {} +} diff --git a/ios-pods-template/App/App/Info.plist b/ios-pods-template/App/App/Info.plist index f8c8a57659..23320dae5e 100644 --- a/ios-pods-template/App/App/Info.plist +++ b/ios-pods-template/App/App/Info.plist @@ -25,26 +25,10 @@ UIApplicationSceneManifest UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - + UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIRequiredDeviceCapabilities armv7 diff --git a/ios-pods-template/App/App/SceneDelegate.swift b/ios-pods-template/App/App/SceneDelegate.swift deleted file mode 100644 index f352e1e959..0000000000 --- a/ios-pods-template/App/App/SceneDelegate.swift +++ /dev/null @@ -1,24 +0,0 @@ -import UIKit -import Capacitor - -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 } - - window = UIWindow(windowScene: windowScene) - window?.rootViewController = CAPBridgeViewController() - window?.makeKeyAndVisible() - - SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) - } - - func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts) - } - - func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { - SceneDelegateProxy.shared.scene(scene, continue: userActivity) - } -} diff --git a/ios-spm-template/App/App.xcodeproj/project.pbxproj b/ios-spm-template/App/App.xcodeproj/project.pbxproj index 87225f719f..269623b05a 100644 --- a/ios-spm-template/App/App.xcodeproj/project.pbxproj +++ b/ios-spm-template/App/App.xcodeproj/project.pbxproj @@ -15,7 +15,8 @@ 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; - 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; }; + 9575E845302683A8004C1ED7 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9575E844302683A6004C1ED7 /* App.swift */; }; + 9575E847302683C1004C1ED7 /* CapacitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9575E846302683BC004C1ED7 /* CapacitorView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,7 +29,8 @@ 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; - 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 9575E844302683A6004C1ED7 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + 9575E846302683BC004C1ED7 /* CapacitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapacitorView.swift; sourceTree = ""; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ @@ -64,7 +66,8 @@ 504EC3061FED79650016851F /* App */ = { isa = PBXGroup; children = ( - 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */, + 9575E846302683BC004C1ED7 /* CapacitorView.swift */, + 9575E844302683A6004C1ED7 /* App.swift */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, @@ -158,8 +161,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9575E845302683A8004C1ED7 /* App.swift in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, - 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */, + 9575E847302683C1004C1ED7 /* CapacitorView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios-spm-template/App/App/App.swift b/ios-spm-template/App/App/App.swift new file mode 100644 index 0000000000..fec128fcef --- /dev/null +++ b/ios-spm-template/App/App/App.swift @@ -0,0 +1,21 @@ +import SwiftUI +import Capacitor + +@main +struct CapacitorApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + CapacitorView() + .ignoresSafeArea() + .onOpenURL { url in + SceneDelegateProxy.shared.handle(openURL: url) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + SceneDelegateProxy.shared.handle(userActivity: activity) + } + } + } +} + diff --git a/ios-spm-template/App/App/AppDelegate.swift b/ios-spm-template/App/App/AppDelegate.swift index 7fe69b516a..5fb4c09fbe 100644 --- a/ios-spm-template/App/App/AppDelegate.swift +++ b/ios-spm-template/App/App/AppDelegate.swift @@ -1,44 +1,7 @@ import UIKit -import Capacitor - -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? +class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. return true } - - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } - - func application(_ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions) -> UISceneConfiguration { - let config = UISceneConfiguration(name: "Default Configuration", - sessionRole: connectingSceneSession.role) - config.delegateClass = SceneDelegate.self - return config - } } diff --git a/ios-spm-template/App/App/CapacitorView.swift b/ios-spm-template/App/App/CapacitorView.swift new file mode 100644 index 0000000000..bae562243f --- /dev/null +++ b/ios-spm-template/App/App/CapacitorView.swift @@ -0,0 +1,11 @@ +import SwiftUI +import Capacitor + +public struct CapacitorView: UIViewControllerRepresentable { + public func makeUIViewController(context: Context) -> CAPBridgeViewController { + CAPBridgeViewController() + } + + public func updateUIViewController(_ vc: CAPBridgeViewController, context: Context) {} +} + diff --git a/ios-spm-template/App/App/SceneDelegate.swift b/ios-spm-template/App/App/SceneDelegate.swift deleted file mode 100644 index f352e1e959..0000000000 --- a/ios-spm-template/App/App/SceneDelegate.swift +++ /dev/null @@ -1,24 +0,0 @@ -import UIKit -import Capacitor - -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 } - - window = UIWindow(windowScene: windowScene) - window?.rootViewController = CAPBridgeViewController() - window?.makeKeyAndVisible() - - SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) - } - - func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts) - } - - func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { - SceneDelegateProxy.shared.scene(scene, continue: userActivity) - } -} diff --git a/ios/Sources/Capacitor/CAPSceneDelegateProxy.swift b/ios/Sources/Capacitor/CAPSceneDelegateProxy.swift index b55d8a9366..bcbbba9fac 100644 --- a/ios/Sources/Capacitor/CAPSceneDelegateProxy.swift +++ b/ios/Sources/Capacitor/CAPSceneDelegateProxy.swift @@ -14,7 +14,10 @@ public class SceneDelegateProxy: NSObject, UISceneDelegate { public private(set) var lastURL: URL? - public func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + public func scene( + _ scene: UIScene, willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { NotificationCenter.default.post(name: .capacitorSceneWillConnect, object: scene) // Plugins haven't loaded yet on a cold start, so notifications posted here are @@ -41,34 +44,150 @@ public class SceneDelegateProxy: NSObject, UISceneDelegate { let options = Self.openURLOptions(from: context.options) // Capacitor 8 backwards compat - NotificationCenter.default.post(name: .capacitorOpenURL, object: [ - "url": context.url, + NotificationCenter.default.post( + name: .capacitorOpenURL, + object: [ + "url": context.url, + "options": options + ]) + + NotificationCenter.default.post( + name: .capacitorSceneOpenURL, object: scene, + userInfo: [ + "url": context.url, + "options": options + ]) + } + } + + public func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let url = userActivity.webpageURL + else { + return + } + lastURL = url + ApplicationDelegateProxy.shared.lastURL = url + + // Capacitor 8 backwards compat + NotificationCenter.default.post( + name: .capacitorOpenUniversalLink, + object: [ + "url": url + ]) + + NotificationCenter.default.post( + name: .capacitorSceneOpenUniversalLink, object: scene, + userInfo: [ + "url": url + ]) + } + + /// Routes a URL into Capacitor's open-URL handlers from a SwiftUI App-struct app. + /// + /// This is the recommended integration point for apps whose root is a `SwiftUI.App` + /// and which therefore do not declare an explicit `UISceneDelegate` subclass. + /// Call it from the `.onOpenURL` modifier inside the scene body: + /// + /// ```swift + /// WindowGroup { + /// CapacitorView() + /// .onOpenURL { url in + /// SceneDelegateProxy.shared.handle(openURL: url) + /// } + /// } + /// ``` + /// + /// Posts the same notifications as the `scene(_:openURLContexts:)` protocol path — + /// both `.capacitorOpenURL` (Capacitor 8 back-compat payload) and + /// `.capacitorSceneOpenURL` (scene-aware, with the resolved scene as the notification + /// object and the URL in `userInfo`). + /// + /// - Parameters: + /// - openURL: The URL to route. + /// - scene: The scene that received the URL. SwiftUI's `.onOpenURL` does not + /// surface a scene reference, so this defaults to `nil`; when `nil`, the active + /// foreground `UIWindowScene` is resolved from + /// `UIApplication.shared.connectedScenes`. For single-scene apps (the Phase 1 + /// default) this is unambiguous; multi-scene URL routing is Phase 2. + public func handle(openURL: URL, scene: UIScene? = nil) { + let targetScene = scene ?? Self.activeForegroundScene() + lastURL = openURL + let options: [UIApplication.OpenURLOptionsKey: Any] = [:] + + // Capacitor 8 backwards compat + NotificationCenter.default.post( + name: .capacitorOpenURL, + object: [ + "url": openURL, "options": options ]) - NotificationCenter.default.post(name: .capacitorSceneOpenURL, object: scene, userInfo: [ - "url": context.url, + NotificationCenter.default.post( + name: .capacitorSceneOpenURL, object: targetScene, + userInfo: [ + "url": openURL, "options": options ]) - } } - public func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + /// Routes a browsing-web `NSUserActivity` into Capacitor's universal-link handlers + /// from a SwiftUI App-struct app. + /// + /// This is the recommended integration point for SwiftUI App-struct apps. Call it + /// from the `.onContinueUserActivity` modifier inside the scene body: + /// + /// ```swift + /// WindowGroup { + /// CapacitorView() + /// .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + /// SceneDelegateProxy.shared.handle(userActivity: activity) + /// } + /// } + /// ``` + /// + /// Only activities with `activityType == NSUserActivityTypeBrowsingWeb` and a + /// non-nil `webpageURL` produce notifications; other activity types are silently + /// ignored, matching the `scene(_:continue:)` protocol path. When a notification is + /// produced, both `.capacitorOpenUniversalLink` (Capacitor 8 back-compat payload) + /// and `.capacitorSceneOpenUniversalLink` (scene-aware) are posted. + /// + /// - Parameters: + /// - userActivity: The activity to inspect. + /// - scene: The scene that received the activity. SwiftUI does not surface a + /// scene reference here either, so this defaults to `nil`; when `nil`, the + /// active foreground `UIWindowScene` is resolved from + /// `UIApplication.shared.connectedScenes`. + public func handle(userActivity: NSUserActivity, scene: UIScene? = nil) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, - let url = userActivity.webpageURL else { + let url = userActivity.webpageURL + else { return } + let targetScene = scene ?? Self.activeForegroundScene() lastURL = url - ApplicationDelegateProxy.shared.lastURL = url // Capacitor 8 backwards compat - NotificationCenter.default.post(name: .capacitorOpenUniversalLink, object: [ - "url": url - ]) + NotificationCenter.default.post( + name: .capacitorOpenUniversalLink, + object: [ + "url": url + ]) + + NotificationCenter.default.post( + name: .capacitorSceneOpenUniversalLink, object: targetScene, + userInfo: [ + "url": url + ]) + } - NotificationCenter.default.post(name: .capacitorSceneOpenUniversalLink, object: scene, userInfo: [ - "url": url - ]) + private static func activeForegroundScene() -> UIWindowScene? { + let scenes = UIApplication.shared.connectedScenes + if let active = scenes.first(where: { $0.activationState == .foregroundActive }) + as? UIWindowScene { + return active + } + return scenes.first(where: { $0.activationState == .foregroundInactive }) as? UIWindowScene } private static func openURLOptions(from sceneOptions: UIScene.OpenURLOptions) -> [UIApplication.OpenURLOptionsKey: Any] { From 0a74c93f01189e9232ab50bc1eaaea411f42e807 Mon Sep 17 00:00:00 2001 From: Joseph Pender Date: Fri, 4 Sep 2026 09:49:25 -0500 Subject: [PATCH 2/5] drop Main.storyboard from the SPM app template --- .../App/App.xcodeproj/project.pbxproj | 12 ----------- .../App/App/Base.lproj/Main.storyboard | 19 ------------------ ios-spm-template/App/App/Info.plist | 20 ++----------------- 3 files changed, 2 insertions(+), 49 deletions(-) delete mode 100644 ios-spm-template/App/App/Base.lproj/Main.storyboard diff --git a/ios-spm-template/App/App.xcodeproj/project.pbxproj b/ios-spm-template/App/App.xcodeproj/project.pbxproj index 269623b05a..943645cc2b 100644 --- a/ios-spm-template/App/App.xcodeproj/project.pbxproj +++ b/ios-spm-template/App/App.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; - 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; @@ -24,7 +23,6 @@ 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -70,7 +68,6 @@ 9575E844302683A6004C1ED7 /* App.swift */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, - 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, 504EC3131FED79650016851F /* Info.plist */, @@ -149,7 +146,6 @@ 50B271D11FEDC1A000F3C39B /* public in Resources */, 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, - 504EC30D1FED79650016851F /* Main.storyboard in Resources */, 2FAD9763203C412B000D30F8 /* config.xml in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -170,14 +166,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ - 504EC30B1FED79650016851F /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 504EC30C1FED79650016851F /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( diff --git a/ios-spm-template/App/App/Base.lproj/Main.storyboard b/ios-spm-template/App/App/Base.lproj/Main.storyboard deleted file mode 100644 index b44df7be8f..0000000000 --- a/ios-spm-template/App/App/Base.lproj/Main.storyboard +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/ios-spm-template/App/App/Info.plist b/ios-spm-template/App/App/Info.plist index fc41ce69bc..22e4257d6b 100644 --- a/ios-spm-template/App/App/Info.plist +++ b/ios-spm-template/App/App/Info.plist @@ -2,7 +2,7 @@ - CAPACITOR_DEBUG + CAPACITOR_DEBUG $(CAPACITOR_DEBUG) CFBundleDevelopmentRegion en @@ -27,26 +27,10 @@ UIApplicationSceneManifest UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - + UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIRequiredDeviceCapabilities armv7 From f8c76d93c242bf7edc050e0ba2ac3a6544b532d8 Mon Sep 17 00:00:00 2001 From: Joseph Pender Date: Mon, 14 Sep 2026 09:48:08 -0500 Subject: [PATCH 3/5] updating migrator for swiftui --- cli/src/tasks/migrate-swiftui.ts | 388 ++++++++++++++++++ cli/src/tasks/migrate-uiscene.ts | 305 -------------- cli/src/tasks/migrate.ts | 4 +- cli/src/util/spm.ts | 71 +++- cli/test/migrate-swiftui-app-delegate.spec.ts | 210 ++++++++++ cli/test/migrate-swiftui-detect.spec.ts | 52 +++ cli/test/migrate-swiftui-plist.spec.ts | 250 +++++++++++ ...n.spec.ts => migrate-swiftui-scan.spec.ts} | 0 cli/test/migrate-uiscene-detect.spec.ts | 36 -- cli/test/migrate-uiscene-plist.spec.ts | 159 ------- .../migrate-uiscene-scene-delegate.spec.ts | 98 ----- cli/test/xcode.spec.ts | 34 +- 12 files changed, 974 insertions(+), 633 deletions(-) create mode 100644 cli/src/tasks/migrate-swiftui.ts delete mode 100644 cli/src/tasks/migrate-uiscene.ts create mode 100644 cli/test/migrate-swiftui-app-delegate.spec.ts create mode 100644 cli/test/migrate-swiftui-detect.spec.ts create mode 100644 cli/test/migrate-swiftui-plist.spec.ts rename cli/test/{migrate-uiscene-scan.spec.ts => migrate-swiftui-scan.spec.ts} (100%) delete mode 100644 cli/test/migrate-uiscene-detect.spec.ts delete mode 100644 cli/test/migrate-uiscene-plist.spec.ts delete mode 100644 cli/test/migrate-uiscene-scene-delegate.spec.ts diff --git a/cli/src/tasks/migrate-swiftui.ts b/cli/src/tasks/migrate-swiftui.ts new file mode 100644 index 0000000000..a8289af46d --- /dev/null +++ b/cli/src/tasks/migrate-swiftui.ts @@ -0,0 +1,388 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs-extra'; +import { join, sep } from 'path'; + +import { runTask } from '../common'; +import type { Config } from '../definitions'; +import { logger } from '../log'; +import { deleteFolderRecursive, readdirp } from '../util/fs'; +import { hasSwiftUISceneManifest, setSwiftUISceneManifest } from '../util/spm'; +import { extractTemplate } from '../util/template'; +import { addSwiftFileToAppTarget } from '../util/xcode'; + +type PreUISceneState = 'eligible' | 'already-migrated' | 'partial'; + +interface UISceneDetectionSignals { + hasSwiftUIManifest: boolean; + hasAppStruct: boolean; + hasCapacitorView: boolean; + hasDelegateAdaptorShape: boolean; +} + +interface TemplateAssets { + app: string; + capacitorView: string; +} + +type AppDelegateRewrite = { status: 'rewritten'; source: string } | { status: 'skipped'; reason: string }; + +const OPEN_URL_SIG = /func application\([^)]*\bopen url:/; +const CONTINUE_SIG = /func application\([^)]*\bcontinue userActivity:/; +const CONFIGURATION_FOR_CONNECTING_SIG = /func application\([^)]*\bconfigurationForConnecting\b/; + +export async function migrateToUIScene(config: Config): Promise { + const signals = readDetectionSignals(config); + const state = classify(signals); + + switch (state) { + case 'already-migrated': + logger.info('UIScene migration: project already uses the SwiftUI App-struct layout, skipping.'); + return; + case 'partial': + logger.warn( + `UIScene migration: project is in a partial state (${describeSignals(signals)}). ` + + `Skipping automated migration — finish the migration by hand or reset the iOS project to a clean 8.4/8.5 state first.`, + ); + return; + case 'eligible': + break; + } + + const appDelegatePath = join(config.ios.nativeTargetDirAbs, 'AppDelegate.swift'); + const rewrite = planAppDelegateRewrite(appDelegatePath); + if (rewrite.status === 'skipped') { + logger.warn(`UIScene migration: skipping automated migration — ${rewrite.reason}`); + printManualSteps(); + return; + } + + const assets = await loadTemplateAssets(config); + if (!assets) { + logger.error('UIScene migration: could not read shipped iOS template assets; skipping.'); + return; + } + + await runTask('Pointing Info.plist at the SwiftUI scene setup.', async () => { + const result = await setSwiftUISceneManifest(config); + if (result.status === 'skipped') { + logger.warn(`Info.plist not updated: ${result.reason}`); + } + }); + + await runTask('Writing App.swift and CapacitorView.swift.', async () => { + const files: [string, string][] = [ + ['App.swift', assets.app], + ['CapacitorView.swift', assets.capacitorView], + ]; + for (const [name, contents] of files) { + if (!writeAppTargetFile(config, name, contents)) { + logger.warn(`${name} already exists, skipping.`); + } + } + }); + + await runTask('Converting AppDelegate.swift into a UIApplicationDelegateAdaptor.', async () => { + writeFileSync(appDelegatePath, rewrite.source); + }); + + await runTask('Registering App.swift and CapacitorView.swift with the Xcode App target.', async () => { + const pbxprojPath = join(config.ios.nativeXcodeProjDirAbs, 'project.pbxproj'); + for (const name of ['App.swift', 'CapacitorView.swift']) { + try { + const { added } = addSwiftFileToAppTarget(pbxprojPath, 'App', name); + if (!added) { + logger.warn(`${name} is already registered in the App target, skipping.`); + } + } catch (err: any) { + logger.warn( + `Could not register ${name} automatically: ${err?.message ?? err}. ` + + `Add ${name} to the App target in Xcode manually.`, + ); + } + } + }); + + await scanAndWarn(config); + printNextSteps(config); +} + +async function scanAndWarn(config: Config): Promise { + const findings: string[] = []; + + const swiftFiles = await readdirp(config.ios.platformDirAbs, { + filter: (item) => { + if (!item.stats.isFile()) return false; + if (!item.path.endsWith('.swift')) return false; + const p = item.path; + return ( + !p.includes(`${sep}Pods${sep}`) && + !p.includes(`${sep}build${sep}`) && + !p.includes(`${sep}DerivedData${sep}`) && + !p.includes(`${sep}.build${sep}`) + ); + }, + }); + + const tokenPatterns: { token: RegExp; label: string }[] = [ + { token: /UIApplication\.shared\.applicationState/, label: 'UIApplication.shared.applicationState' }, + { token: /\btmpWindow\b/, label: 'tmpWindow' }, + { token: /\bTmpViewController\b/, label: 'TmpViewController' }, + ]; + + for (const filePath of swiftFiles) { + const source = readFileSync(filePath, 'utf-8'); + source.split('\n').forEach((line, idx) => { + for (const { token, label } of tokenPatterns) { + if (token.test(line)) { + findings.push(`${filePath}:${idx + 1}: uses ${label}`); + } + } + }); + } + + const appDelegatePath = join(config.ios.nativeTargetDirAbs, 'AppDelegate.swift'); + if (existsSync(appDelegatePath)) { + const source = readFileSync(appDelegatePath, 'utf-8'); + if (hasCustomDelegateBody(source, OPEN_URL_SIG)) { + findings.push(`${appDelegatePath}: custom application(_:open:) body — review for UIScene compatibility`); + } + if (hasCustomDelegateBody(source, CONTINUE_SIG)) { + findings.push(`${appDelegatePath}: custom application(_:continue:) body — review for UIScene compatibility`); + } + } + + if (findings.length === 0) return; + + logger.warn('UIScene scan found patterns that may need manual review:'); + for (const finding of findings) { + logger.warn(` ${finding}`); + } +} + +function hasCustomDelegateBody(source: string, sigRegex: RegExp): boolean { + const match = source.match(sigRegex); + if (!match || match.index === undefined) return false; + const openIdx = source.indexOf('{', match.index); + if (openIdx === -1) return false; + const closeIdx = findMatchingBrace(source, openIdx); + if (closeIdx === null) return false; + const body = source.slice(openIdx + 1, closeIdx); + const codeLines = body + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith('//')); + if (codeLines.length === 0) return false; + return codeLines.some((l) => !l.includes('ApplicationDelegateProxy.shared')); +} + +function hasCustomWindowSetup(source: string): boolean { + return source.split('\n').some((line) => { + const trimmed = line.trim(); + if (!/\bwindow\b/.test(trimmed)) return false; + if (trimmed.startsWith('//')) return false; + return !/^var\s+window\s*:\s*UIWindow\?$/.test(trimmed); + }); +} + +function printNextSteps(config: Config): void { + logger.info(''); + logger.info('UIScene migration next steps:'); + logger.info(' • Review any warnings above for legacy API usage or custom AppDelegate URL/activity handlers.'); + logger.info(' • App.swift now owns the app entry point; move UIKit root-window customizations into its scene body.'); + for (const leftover of leftoverUIKitFiles(config)) { + logger.info(` • ${leftover} is no longer used by the app and can be deleted from the Xcode project.`); + } + logger.info(' • Full guide: https://capacitorjs.com/docs/next/updating/9-0'); +} + +function printManualSteps(): void { + logger.info(''); + logger.info('Migrate the iOS project to the SwiftUI App-struct layout by hand:'); + logger.info(' • Add App.swift (an @main struct conforming to App) and CapacitorView.swift to the App target.'); + logger.info(' • Drop @main, the UIWindow property, and configurationForConnecting from AppDelegate.'); + logger.info(' • Reference AppDelegate from App.swift with @UIApplicationDelegateAdaptor.'); + logger.info(' • Reduce UIApplicationSceneManifest to UIApplicationSupportsMultipleScenes only.'); + logger.info(' • Remove UIMainStoryboardFile from Info.plist.'); + logger.info(' • Route URLs and universal links from the SwiftUI scene body through'); + logger.info(' SceneDelegateProxy.shared.handle(openURL:) and .handle(userActivity:).'); + logger.info(' • Full guide: https://capacitorjs.com/docs/next/updating/9-0'); +} + +function leftoverUIKitFiles(config: Config): string[] { + const candidates = [ + join(config.ios.nativeTargetDirAbs, 'SceneDelegate.swift'), + join(config.ios.nativeTargetDirAbs, 'Base.lproj', 'Main.storyboard'), + ]; + return candidates.filter((path) => existsSync(path)); +} + +async function loadTemplateAssets(config: Config): Promise { + const packageManager = await config.ios.packageManager; + const archiveName = packageManager === 'SPM' ? 'ios-spm-template.tar.gz' : 'ios-pods-template.tar.gz'; + const archivePath = join(config.cli.assetsDirAbs, archiveName); + const tempDir = join(config.cli.assetsDirAbs, 'tempUISceneTemplate'); + + try { + await extractTemplate(archivePath, tempDir); + const appPath = join(tempDir, 'App', 'App', 'App.swift'); + const capacitorViewPath = join(tempDir, 'App', 'App', 'CapacitorView.swift'); + if (!existsSync(appPath) || !existsSync(capacitorViewPath)) { + return null; + } + return { + app: readFileSync(appPath, 'utf-8'), + capacitorView: readFileSync(capacitorViewPath, 'utf-8'), + }; + } finally { + deleteFolderRecursive(tempDir); + } +} + +function writeAppTargetFile(config: Config, name: string, contents: string): boolean { + const path = join(config.ios.nativeTargetDirAbs, name); + if (existsSync(path)) { + return false; + } + writeFileSync(path, contents); + return true; +} + +function planAppDelegateRewrite(path: string): AppDelegateRewrite { + if (!existsSync(path)) { + return { status: 'skipped', reason: 'AppDelegate.swift not found.' }; + } + return rewriteAppDelegateForAdaptor(readFileSync(path, 'utf-8')); +} + +/** + * Reshape a pre-9.0 AppDelegate into one that can be adopted by an @main SwiftUI App + * struct through @UIApplicationDelegateAdaptor. + * + * Refuses the rewrite whenever the class does more than the stock template does, because + * SwiftUI takes over both the window and the scene-level URL/activity callbacks. + */ +function rewriteAppDelegateForAdaptor(source: string): AppDelegateRewrite { + if (!/\bclass\s+AppDelegate\b/.test(source)) { + return { status: 'skipped', reason: 'no AppDelegate class found in AppDelegate.swift.' }; + } + if (hasCustomWindowSetup(source)) { + return { + status: 'skipped', + reason: 'AppDelegate manages its own UIWindow, which the SwiftUI App struct now owns.', + }; + } + if (hasCustomDelegateBody(source, OPEN_URL_SIG) || hasCustomDelegateBody(source, CONTINUE_SIG)) { + return { + status: 'skipped', + reason: + 'AppDelegate has custom application(_:open:) or application(_:continue:) handling, ' + + 'which scenes no longer call.', + }; + } + + let rewritten = stripMainAttribute(source); + rewritten = rewritten.replace(/(\bclass\s+AppDelegate\s*:\s*)UIResponder\b/, '$1NSObject'); + rewritten = rewritten.replace(/^[ \t]*var\s+window\s*:\s*UIWindow\?[ \t]*\r?\n(?:[ \t]*\r?\n)?/m, ''); + + const withoutSceneConfiguration = removeMethod(rewritten, CONFIGURATION_FOR_CONNECTING_SIG); + if (withoutSceneConfiguration === null) { + return { status: 'skipped', reason: 'could not parse the configurationForConnecting method in AppDelegate.swift.' }; + } + + return { status: 'rewritten', source: withoutSceneConfiguration }; +} + +function stripMainAttribute(source: string): string { + return source.replace(/@(?:main|UIApplicationMain)[ \t]*(?:\r?\n[ \t]*)?(?=class\s+AppDelegate\b)/, ''); +} + +function removeMethod(source: string, sigRegex: RegExp): string | null { + const match = source.match(sigRegex); + if (!match || match.index === undefined) { + return source; + } + const openIdx = source.indexOf('{', match.index); + if (openIdx === -1) { + return null; + } + const closeIdx = findMatchingBrace(source, openIdx); + if (closeIdx === null) { + return null; + } + + const lines = source.split('\n'); + const signatureLine = countNewlines(source.slice(0, match.index)); + const closingLine = countNewlines(source.slice(0, closeIdx)); + const firstLine = signatureLine > 0 && lines[signatureLine - 1].trim() === '' ? signatureLine - 1 : signatureLine; + + lines.splice(firstLine, closingLine - firstLine + 1); + return lines.join('\n'); +} + +function findMatchingBrace(source: string, openIdx: number): number | null { + let depth = 1; + let i = openIdx + 1; + while (i < source.length && depth > 0) { + const ch = source[i]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + return depth === 0 ? i - 1 : null; +} + +function countNewlines(source: string): number { + let count = 0; + for (const ch of source) { + if (ch === '\n') count++; + } + return count; +} + +function readDetectionSignals(config: Config): UISceneDetectionSignals { + const appStructPath = join(config.ios.nativeTargetDirAbs, 'App.swift'); + const capacitorViewPath = join(config.ios.nativeTargetDirAbs, 'CapacitorView.swift'); + const appDelegatePath = join(config.ios.nativeTargetDirAbs, 'AppDelegate.swift'); + + return { + hasSwiftUIManifest: hasSwiftUISceneManifest(config), + hasAppStruct: existsSync(appStructPath) && /struct\s+\w+\s*:\s*App\b/.test(readFileSync(appStructPath, 'utf-8')), + hasCapacitorView: existsSync(capacitorViewPath), + hasDelegateAdaptorShape: + existsSync(appDelegatePath) && !/@(?:main|UIApplicationMain)\b/.test(readFileSync(appDelegatePath, 'utf-8')), + }; +} + +function classify(signals: UISceneDetectionSignals): PreUISceneState { + const { hasSwiftUIManifest, hasAppStruct, hasCapacitorView, hasDelegateAdaptorShape } = signals; + const trueCount = [hasSwiftUIManifest, hasAppStruct, hasCapacitorView, hasDelegateAdaptorShape].filter( + Boolean, + ).length; + if (trueCount === 0) return 'eligible'; + if (trueCount === 4) return 'already-migrated'; + return 'partial'; +} + +function describeSignals({ + hasSwiftUIManifest, + hasAppStruct, + hasCapacitorView, + hasDelegateAdaptorShape, +}: UISceneDetectionSignals): string { + const present: string[] = []; + const missing: string[] = []; + (hasSwiftUIManifest ? present : missing).push('SwiftUI UIApplicationSceneManifest'); + (hasAppStruct ? present : missing).push('App.swift'); + (hasCapacitorView ? present : missing).push('CapacitorView.swift'); + (hasDelegateAdaptorShape ? present : missing).push('AppDelegate without @main'); + return `present: [${present.join(', ')}]; missing: [${missing.join(', ')}]`; +} + +// Exported for tests. +export const __testables = { + classify, + describeSignals, + hasCustomDelegateBody, + hasCustomWindowSetup, + rewriteAppDelegateForAdaptor, + scanAndWarn, +}; diff --git a/cli/src/tasks/migrate-uiscene.ts b/cli/src/tasks/migrate-uiscene.ts deleted file mode 100644 index 9f6dfa2789..0000000000 --- a/cli/src/tasks/migrate-uiscene.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs-extra'; -import { join, sep } from 'path'; - -import { runTask } from '../common'; -import type { Config } from '../definitions'; -import { logger } from '../log'; -import { deleteFolderRecursive, readdirp } from '../util/fs'; -import { addSceneManifestIfNeeded, hasSceneManifest } from '../util/spm'; -import { extractTemplate } from '../util/template'; -import { addSwiftFileToAppTarget } from '../util/xcode'; - -type PreUISceneState = 'eligible' | 'already-migrated' | 'partial'; - -interface UISceneDetectionSignals { - hasManifest: boolean; - hasSceneDelegate: boolean; - hasConfigurationForConnecting: boolean; -} - -interface TemplateAssets { - sceneDelegate: string; - configurationForConnectingSnippet: string; -} - -export async function migrateToUIScene(config: Config): Promise { - const signals = readDetectionSignals(config); - const state = classify(signals); - - switch (state) { - case 'already-migrated': - logger.info('UIScene migration: project already migrated, skipping.'); - return; - case 'partial': - logger.warn( - `UIScene migration: project is in a partial state (${describeSignals(signals)}). ` + - `Skipping automated migration — finish the migration by hand or reset to a clean 8.4 state first.`, - ); - return; - case 'eligible': - break; - } - - const assets = await loadTemplateAssets(config); - if (!assets) { - logger.error('UIScene migration: could not read shipped iOS template assets; skipping.'); - return; - } - - await runTask('Adding UIApplicationSceneManifest to Info.plist.', () => addSceneManifestIfNeeded(config)); - - await runTask('Writing SceneDelegate.swift.', async () => { - const { written } = writeSceneDelegate(config, assets.sceneDelegate); - if (!written) { - logger.warn('SceneDelegate.swift already exists, skipping.'); - } - }); - - await runTask('Patching AppDelegate.swift with configurationForConnecting.', async () => { - const { patched, reason } = patchAppDelegate(config, assets.configurationForConnectingSnippet); - if (!patched && reason) { - logger.warn(`AppDelegate.swift not patched: ${reason}`); - } - }); - - await runTask('Registering SceneDelegate.swift with the Xcode App target.', async () => { - const pbxprojPath = join(config.ios.nativeXcodeProjDirAbs, 'project.pbxproj'); - try { - const { added } = addSwiftFileToAppTarget(pbxprojPath, 'App', 'SceneDelegate.swift'); - if (!added) { - logger.warn('SceneDelegate.swift is already registered in the App target, skipping.'); - } - } catch (err: any) { - logger.warn( - `Could not register SceneDelegate.swift automatically: ${err?.message ?? err}. ` + - 'Add SceneDelegate.swift to the App target in Xcode manually.', - ); - } - }); - - await scanAndWarn(config); - printNextSteps(); -} - -async function scanAndWarn(config: Config): Promise { - const findings: string[] = []; - - const swiftFiles = await readdirp(config.ios.platformDirAbs, { - filter: (item) => { - if (!item.stats.isFile()) return false; - if (!item.path.endsWith('.swift')) return false; - const p = item.path; - return ( - !p.includes(`${sep}Pods${sep}`) && - !p.includes(`${sep}build${sep}`) && - !p.includes(`${sep}DerivedData${sep}`) && - !p.includes(`${sep}.build${sep}`) - ); - }, - }); - - const tokenPatterns: { token: RegExp; label: string }[] = [ - { token: /UIApplication\.shared\.applicationState/, label: 'UIApplication.shared.applicationState' }, - { token: /\btmpWindow\b/, label: 'tmpWindow' }, - { token: /\bTmpViewController\b/, label: 'TmpViewController' }, - ]; - - for (const filePath of swiftFiles) { - const source = readFileSync(filePath, 'utf-8'); - source.split('\n').forEach((line, idx) => { - for (const { token, label } of tokenPatterns) { - if (token.test(line)) { - findings.push(`${filePath}:${idx + 1}: uses ${label}`); - } - } - }); - } - - const appDelegatePath = join(config.ios.nativeTargetDirAbs, 'AppDelegate.swift'); - if (existsSync(appDelegatePath)) { - const source = readFileSync(appDelegatePath, 'utf-8'); - if (hasCustomDelegateBody(source, /func application\([^)]*\bopen url:/)) { - findings.push(`${appDelegatePath}: custom application(_:open:) body — review for UIScene compatibility`); - } - if (hasCustomDelegateBody(source, /func application\([^)]*\bcontinue userActivity:/)) { - findings.push(`${appDelegatePath}: custom application(_:continue:) body — review for UIScene compatibility`); - } - } - - if (findings.length === 0) return; - - logger.warn('UIScene scan found patterns that may need manual review:'); - for (const finding of findings) { - logger.warn(` ${finding}`); - } -} - -function hasCustomDelegateBody(source: string, sigRegex: RegExp): boolean { - const match = source.match(sigRegex); - if (!match || match.index === undefined) return false; - const openIdx = source.indexOf('{', match.index); - if (openIdx === -1) return false; - let depth = 1; - let i = openIdx + 1; - while (i < source.length && depth > 0) { - const ch = source[i]; - if (ch === '{') depth++; - else if (ch === '}') depth--; - i++; - } - if (depth !== 0) return false; - const body = source.slice(openIdx + 1, i - 1); - const codeLines = body - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.length > 0 && !l.startsWith('//')); - if (codeLines.length === 0) return false; - return codeLines.some((l) => !l.includes('ApplicationDelegateProxy.shared')); -} - -function printNextSteps(): void { - logger.info(''); - logger.info('UIScene migration next steps:'); - logger.info(' • Review any warnings above for legacy API usage or custom AppDelegate URL/activity handlers.'); - logger.info(' • Full guide: https://capacitorjs.com/docs/updating/8-5'); -} - -async function loadTemplateAssets(config: Config): Promise { - const packageManager = await config.ios.packageManager; - const archiveName = packageManager === 'SPM' ? 'ios-spm-template.tar.gz' : 'ios-pods-template.tar.gz'; - const archivePath = join(config.cli.assetsDirAbs, archiveName); - const tempDir = join(config.cli.assetsDirAbs, 'tempUISceneTemplate'); - - try { - await extractTemplate(archivePath, tempDir); - const sceneDelegatePath = join(tempDir, 'App', 'App', 'SceneDelegate.swift'); - const appDelegatePath = join(tempDir, 'App', 'App', 'AppDelegate.swift'); - if (!existsSync(sceneDelegatePath) || !existsSync(appDelegatePath)) { - return null; - } - const sceneDelegate = readFileSync(sceneDelegatePath, 'utf-8'); - const appDelegateSource = readFileSync(appDelegatePath, 'utf-8'); - const configurationForConnectingSnippet = extractConfigurationForConnecting(appDelegateSource); - if (!configurationForConnectingSnippet) { - return null; - } - return { sceneDelegate, configurationForConnectingSnippet }; - } finally { - deleteFolderRecursive(tempDir); - } -} - -function writeSceneDelegate(config: Config, contents: string): { written: boolean } { - const path = join(config.ios.nativeTargetDirAbs, 'SceneDelegate.swift'); - if (existsSync(path)) { - return { written: false }; - } - writeFileSync(path, contents); - return { written: true }; -} - -function patchAppDelegate(config: Config, snippet: string): { patched: boolean; reason?: string } { - const path = join(config.ios.nativeTargetDirAbs, 'AppDelegate.swift'); - if (!existsSync(path)) { - return { patched: false, reason: 'AppDelegate.swift not found.' }; - } - const source = readFileSync(path, 'utf-8'); - if (source.includes('UISceneConfiguration(name:')) { - return { patched: false, reason: 'configurationForConnecting already present.' }; - } - const patched = insertBeforeAppDelegateClassEnd(source, snippet); - if (!patched) { - return { patched: false, reason: 'could not locate AppDelegate class body.' }; - } - writeFileSync(path, patched); - return { patched: true }; -} - -function extractConfigurationForConnecting(appDelegateSource: string): string | null { - const sigRegex = /^ {4}func application\(_ application: UIApplication,\n {21}configurationForConnecting\b/m; - const sigMatch = appDelegateSource.match(sigRegex); - if (!sigMatch || sigMatch.index === undefined) { - return null; - } - const openIdx = appDelegateSource.indexOf('{', sigMatch.index); - if (openIdx === -1) { - return null; - } - let depth = 1; - let i = openIdx + 1; - while (i < appDelegateSource.length && depth > 0) { - const ch = appDelegateSource[i]; - if (ch === '{') depth++; - else if (ch === '}') depth--; - i++; - } - if (depth !== 0) { - return null; - } - return '\n' + appDelegateSource.slice(sigMatch.index, i) + '\n'; -} - -function insertBeforeAppDelegateClassEnd(source: string, snippet: string): string | null { - const classDeclRegex = /\bclass\s+AppDelegate\b[^{]*\{/; - const match = source.match(classDeclRegex); - if (!match || match.index === undefined) { - return null; - } - const openIdx = source.indexOf('{', match.index); - let depth = 1; - let i = openIdx + 1; - while (i < source.length && depth > 0) { - const ch = source[i]; - if (ch === '{') depth++; - else if (ch === '}') depth--; - i++; - } - if (depth !== 0) { - return null; - } - const closeIdx = i - 1; - return source.slice(0, closeIdx) + snippet + source.slice(closeIdx); -} - -function readDetectionSignals(config: Config): UISceneDetectionSignals { - const sceneDelegatePath = join(config.ios.nativeTargetDirAbs, 'SceneDelegate.swift'); - const appDelegatePath = join(config.ios.nativeTargetDirAbs, 'AppDelegate.swift'); - - return { - hasManifest: hasSceneManifest(config), - hasSceneDelegate: existsSync(sceneDelegatePath), - hasConfigurationForConnecting: - existsSync(appDelegatePath) && readFileSync(appDelegatePath, 'utf-8').includes('UISceneConfiguration(name:'), - }; -} - -function classify(signals: UISceneDetectionSignals): PreUISceneState { - const { hasManifest, hasSceneDelegate, hasConfigurationForConnecting } = signals; - const trueCount = [hasManifest, hasSceneDelegate, hasConfigurationForConnecting].filter(Boolean).length; - if (trueCount === 0) return 'eligible'; - if (trueCount === 3) return 'already-migrated'; - return 'partial'; -} - -function describeSignals({ - hasManifest, - hasSceneDelegate, - hasConfigurationForConnecting, -}: UISceneDetectionSignals): string { - const present: string[] = []; - const missing: string[] = []; - (hasManifest ? present : missing).push('UIApplicationSceneManifest'); - (hasSceneDelegate ? present : missing).push('SceneDelegate.swift'); - (hasConfigurationForConnecting ? present : missing).push('AppDelegate.configurationForConnecting'); - return `present: [${present.join(', ')}]; missing: [${missing.join(', ')}]`; -} - -// Exported for tests. -export const __testables = { - classify, - describeSignals, - insertBeforeAppDelegateClassEnd, - extractConfigurationForConnecting, - hasCustomDelegateBody, - scanAndWarn, -}; diff --git a/cli/src/tasks/migrate.ts b/cli/src/tasks/migrate.ts index dcc8898fdd..291fde253d 100644 --- a/cli/src/tasks/migrate.ts +++ b/cli/src/tasks/migrate.ts @@ -470,8 +470,8 @@ async function writeBreakingChanges() { } if (allDependencies['@capacitor/ios']) { logger.info( - 'IMPORTANT: Capacitor 8.5 adopts UIScene on iOS. ' + - 'See https://capacitorjs.com/docs/updating/8-5 for the full 8.4 → 8.5 migration guide.', + 'IMPORTANT: Capacitor 9.0 boots iOS apps from a SwiftUI App struct with UIScene. ' + + 'See https://capacitorjs.com/docs/next/updating/9-0 for the full migration guide.', ); } } diff --git a/cli/src/util/spm.ts b/cli/src/util/spm.ts index 47bf559aeb..404fd75f8e 100644 --- a/cli/src/util/spm.ts +++ b/cli/src/util/spm.ts @@ -249,6 +249,9 @@ export async function addInfoPlistDebugIfNeeded(config: Config): Promise { } } +export type SceneManifestResult = + { status: 'written' } | { status: 'unchanged' } | { status: 'skipped'; reason: string }; + export function hasSceneManifest(config: Config): boolean { const infoPlist = resolve(config.ios.nativeTargetDirAbs, 'Info.plist'); if (!existsSync(infoPlist)) { @@ -258,37 +261,73 @@ export function hasSceneManifest(config: Config): boolean { return entries['UIApplicationSceneManifest'] !== undefined; } -export async function addSceneManifestIfNeeded(config: Config): Promise { +export function hasSwiftUISceneManifest(config: Config): boolean { + const infoPlist = resolve(config.ios.nativeTargetDirAbs, 'Info.plist'); + if (!existsSync(infoPlist)) { + return false; + } + const entries = parse(readFileSync(infoPlist, 'utf-8')) as PlistObject; + const manifest = entries['UIApplicationSceneManifest'] as PlistObject | undefined; + return manifest !== undefined && manifest['UISceneConfigurations'] === undefined; +} + +/** + * Point Info.plist at the SwiftUI App-struct scene setup: a scene manifest with no + * UISceneConfigurations (SwiftUI declares the scene itself) and no main storyboard. + * + * Preserves UIApplicationSupportsMultipleScenes when the project already set it, and + * refuses to touch a manifest that names a scene delegate class other than the stock + * SceneDelegate. + */ +export async function setSwiftUISceneManifest(config: Config): Promise { type Mutable = { -readonly [P in keyof T]: T[P] }; const infoPlist = resolve(config.ios.nativeTargetDirAbs, 'Info.plist'); if (!existsSync(infoPlist)) { - logger.warn(infoPlist + ' not found.'); - return; + return { status: 'skipped', reason: `${infoPlist} not found.` }; } const entries = parse(readFileSync(infoPlist, 'utf-8')) as Mutable; + const manifest = entries['UIApplicationSceneManifest'] as PlistObject | undefined; + + const customDelegates = customSceneDelegateClassNames(manifest); + if (customDelegates.length > 0) { + return { + status: 'skipped', + reason: + `UIApplicationSceneManifest names custom scene delegate(s): ${customDelegates.join(', ')}. ` + + 'Remove UISceneConfigurations by hand once the delegate is ported to the SwiftUI scene body.', + }; + } - if (entries['UIApplicationSceneManifest'] !== undefined) { - logger.warn('Found UIApplicationSceneManifest in ' + infoPlist + ', skipping.'); - return; + if ( + manifest !== undefined && + manifest['UISceneConfigurations'] === undefined && + !('UIMainStoryboardFile' in entries) + ) { + return { status: 'unchanged' }; } entries['UIApplicationSceneManifest'] = { - UIApplicationSupportsMultipleScenes: false, - UISceneConfigurations: { - UIWindowSceneSessionRoleApplication: [ - { - UISceneConfigurationName: 'Default Configuration', - UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate', - UISceneStoryboardFile: 'Main', - }, - ], - }, + UIApplicationSupportsMultipleScenes: manifest?.['UIApplicationSupportsMultipleScenes'] ?? false, }; + delete entries['UIMainStoryboardFile']; writeFileSync(infoPlist, build(entries)); + return { status: 'written' }; +} + +function customSceneDelegateClassNames(manifest: PlistObject | undefined): string[] { + const configurations = manifest?.['UISceneConfigurations'] as PlistObject | undefined; + if (!configurations) { + return []; + } + return Object.values(configurations) + .flatMap((roleConfigs) => (Array.isArray(roleConfigs) ? (roleConfigs as PlistObject[]) : [])) + .map((roleConfig) => roleConfig['UISceneDelegateClassName']) + .filter((className): className is string => typeof className === 'string') + .filter((className) => className.split('.').pop() !== 'SceneDelegate'); } export async function checkSwiftToolsVersion(config: Config, version: string | undefined): Promise { diff --git a/cli/test/migrate-swiftui-app-delegate.spec.ts b/cli/test/migrate-swiftui-app-delegate.spec.ts new file mode 100644 index 0000000000..41fe6d4766 --- /dev/null +++ b/cli/test/migrate-swiftui-app-delegate.spec.ts @@ -0,0 +1,210 @@ +import { __testables } from '../src/tasks/migrate-uiscene'; + +const { rewriteAppDelegateForAdaptor, hasCustomWindowSetup } = __testables; + +// The AppDelegate shipped by the 8.4 templates: @main, UIResponder, own window. +const APP_DELEGATE_8_4 = `import UIKit +import Capacitor + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } +} +`; + +// The AppDelegate shipped by the 8.5 templates: same, plus configurationForConnecting. +const APP_DELEGATE_8_5 = `import UIKit +import Capacitor + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default Configuration", + sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } +} +`; + +function rewrite(source: string): string { + const result = rewriteAppDelegateForAdaptor(source); + if (result.status !== 'rewritten') { + throw new Error(`expected a rewrite, got: ${result.reason}`); + } + return result.source; +} + +describe('rewriteAppDelegateForAdaptor', () => { + it('converts the 8.4 AppDelegate into adaptor shape', () => { + const patched = rewrite(APP_DELEGATE_8_4); + + expect(patched).not.toContain('@main'); + expect(patched).toContain('class AppDelegate: NSObject, UIApplicationDelegate {'); + expect(patched).not.toContain('var window: UIWindow?'); + expect(patched).toContain('didFinishLaunchingWithOptions'); + }); + + it('converts an @UIApplicationMain AppDelegate too', () => { + const patched = rewrite(APP_DELEGATE_8_4.replace('@main', '@UIApplicationMain')); + + expect(patched).not.toContain('@UIApplicationMain'); + expect(patched).toContain('class AppDelegate: NSObject, UIApplicationDelegate {'); + }); + + it('strips a @main written on the class declaration line', () => { + const patched = rewrite(APP_DELEGATE_8_4.replace('@main\nclass', '@main class')); + + expect(patched).not.toContain('@main'); + expect(patched).toContain('class AppDelegate: NSObject, UIApplicationDelegate {'); + }); + + it('removes configurationForConnecting from the 8.5 AppDelegate', () => { + const patched = rewrite(APP_DELEGATE_8_5); + + expect(patched).not.toContain('configurationForConnecting'); + expect(patched).not.toContain('UISceneConfiguration'); + expect(patched).not.toContain('config.delegateClass'); + expect(patched).toContain('didFinishLaunchingWithOptions'); + expect(patched).not.toMatch(/\n\n\n/); + }); + + it('leaves the 8.5 AppDelegate closing the class cleanly', () => { + const patched = rewrite(APP_DELEGATE_8_5); + + expect(patched).toBe(`import UIKit +import Capacitor + +class AppDelegate: NSObject, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } +} +`); + }); + + it('is idempotent — a rewritten AppDelegate rewrites to itself', () => { + const once = rewrite(APP_DELEGATE_8_5); + + expect(rewrite(once)).toBe(once); + }); + + it('keeps a vanilla application(_:open:) body', () => { + const patched = rewrite(APP_DELEGATE_8_4); + + expect(patched).toContain('ApplicationDelegateProxy.shared.application(app, open: url, options: options)'); + }); + + it('refuses when no AppDelegate class is present', () => { + const result = rewriteAppDelegateForAdaptor('import UIKit\n\nstruct Something {}\n'); + + expect(result.status).toBe('skipped'); + expect(result.status === 'skipped' && result.reason).toMatch(/no AppDelegate class/); + }); + + it('refuses when the AppDelegate sets up its own window', () => { + const source = APP_DELEGATE_8_4.replace( + ' return true', + ' window = UIWindow(frame: UIScreen.main.bounds)\n window?.makeKeyAndVisible()\n return true', + ); + + const result = rewriteAppDelegateForAdaptor(source); + + expect(result.status).toBe('skipped'); + expect(result.status === 'skipped' && result.reason).toMatch(/UIWindow/); + }); + + it('refuses when application(_:open:) has a custom body', () => { + const source = APP_DELEGATE_8_4.replace( + ' return ApplicationDelegateProxy.shared.application(app, open: url, options: options)', + ' MyAnalytics.track(url)\n return ApplicationDelegateProxy.shared.application(app, open: url, options: options)', + ); + + const result = rewriteAppDelegateForAdaptor(source); + + expect(result.status).toBe('skipped'); + expect(result.status === 'skipped' && result.reason).toMatch(/application\(_:open:\)/); + }); + + it('refuses when application(_:continue:) has a custom body', () => { + const source = `import UIKit +import Capacitor + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + MyAnalytics.track(userActivity) + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } +} +`; + + const result = rewriteAppDelegateForAdaptor(source); + + expect(result.status).toBe('skipped'); + expect(result.status === 'skipped' && result.reason).toMatch(/application\(_:continue:\)/); + }); + + it('refuses when configurationForConnecting braces are unbalanced', () => { + const source = `class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) +`; + + const result = rewriteAppDelegateForAdaptor(source); + + expect(result.status).toBe('skipped'); + expect(result.status === 'skipped' && result.reason).toMatch(/configurationForConnecting/); + }); +}); + +describe('hasCustomWindowSetup', () => { + it('returns false for the bare template window property', () => { + expect(hasCustomWindowSetup(APP_DELEGATE_8_4)).toBe(false); + }); + + it('returns false for an AppDelegate with no window at all', () => { + expect(hasCustomWindowSetup('class AppDelegate: NSObject, UIApplicationDelegate {}\n')).toBe(false); + }); + + it('returns true when the window is assigned', () => { + expect(hasCustomWindowSetup(' var window: UIWindow?\n window = UIWindow()\n')).toBe(true); + }); + + it('returns true when a root view controller is installed', () => { + expect(hasCustomWindowSetup(' window?.rootViewController = MyViewController()\n')).toBe(true); + }); + + it('ignores commented-out window code', () => { + expect(hasCustomWindowSetup(' // window = UIWindow()\n var window: UIWindow?\n')).toBe(false); + }); + + it('does not trip on UIWindowScene references', () => { + expect(hasCustomWindowSetup(' let scene = connectingSceneSession as? UIWindowScene\n')).toBe(false); + }); +}); diff --git a/cli/test/migrate-swiftui-detect.spec.ts b/cli/test/migrate-swiftui-detect.spec.ts new file mode 100644 index 0000000000..b81fcd2462 --- /dev/null +++ b/cli/test/migrate-swiftui-detect.spec.ts @@ -0,0 +1,52 @@ +import { __testables } from '../src/tasks/migrate-uiscene'; + +const { classify, describeSignals } = __testables; + +const NOTHING_MIGRATED = { + hasSwiftUIManifest: false, + hasAppStruct: false, + hasCapacitorView: false, + hasDelegateAdaptorShape: false, +}; + +const FULLY_MIGRATED = { + hasSwiftUIManifest: true, + hasAppStruct: true, + hasCapacitorView: true, + hasDelegateAdaptorShape: true, +}; + +describe('migrate-uiscene classify', () => { + it('returns eligible for a project with none of the SwiftUI App-struct markers', () => { + expect(classify(NOTHING_MIGRATED)).toBe('eligible'); + }); + + it('returns already-migrated when every marker is present', () => { + expect(classify(FULLY_MIGRATED)).toBe('already-migrated'); + }); + + it.each(Object.keys(NOTHING_MIGRATED))('returns partial when only %s is present', (key) => { + expect(classify({ ...NOTHING_MIGRATED, [key]: true })).toBe('partial'); + }); + + it.each(Object.keys(FULLY_MIGRATED))('returns partial when only %s is missing', (key) => { + expect(classify({ ...FULLY_MIGRATED, [key]: false })).toBe('partial'); + }); +}); + +describe('migrate-uiscene describeSignals', () => { + it('reports every marker as missing for an unmigrated project', () => { + const description = describeSignals(NOTHING_MIGRATED); + + expect(description).toContain('present: []'); + expect(description).toContain('App.swift'); + expect(description).toContain('CapacitorView.swift'); + }); + + it('splits present and missing markers', () => { + const description = describeSignals({ ...NOTHING_MIGRATED, hasCapacitorView: true }); + + expect(description).toMatch(/present: \[CapacitorView\.swift\]/); + expect(description).toMatch(/missing: \[.*App\.swift.*\]/); + }); +}); diff --git a/cli/test/migrate-swiftui-plist.spec.ts b/cli/test/migrate-swiftui-plist.spec.ts new file mode 100644 index 0000000000..4514e88bf9 --- /dev/null +++ b/cli/test/migrate-swiftui-plist.spec.ts @@ -0,0 +1,250 @@ +import { readFileSync, writeFileSync } from 'fs-extra'; +import { join } from 'path'; +import type { PlistObject } from 'plist'; +import { parse } from 'plist'; + +import type { Config } from '../src/definitions'; +import { hasSceneManifest, hasSwiftUISceneManifest, setSwiftUISceneManifest } from '../src/util/spm'; + +import { mktmp } from './util'; + +const INFO_PLIST_8_4 = ` + + + + CFBundleDisplayName + My App + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + + +`; + +const INFO_PLIST_8_5 = ` + + + + CFBundleDisplayName + My App + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + +`; + +const INFO_PLIST_CUSTOM_SCENE_DELEGATE = INFO_PLIST_8_5.replace( + '$(PRODUCT_MODULE_NAME).SceneDelegate', + '$(PRODUCT_MODULE_NAME).CustomSceneDelegate', +); + +const INFO_PLIST_9_0 = ` + + + + CFBundleDisplayName + My App + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + +`; + +function makeFakeConfig(nativeTargetDirAbs: string): Config { + return { ios: { nativeTargetDirAbs } } as unknown as Config; +} + +function readManifest(plistPath: string): PlistObject { + const parsed = parse(readFileSync(plistPath, 'utf-8')) as PlistObject; + return parsed['UIApplicationSceneManifest'] as PlistObject; +} + +describe('setSwiftUISceneManifest', () => { + let tmpDir: any; + let plistPath: string; + let config: Config; + + beforeEach(async () => { + tmpDir = await mktmp(); + plistPath = join(tmpDir.path, 'Info.plist'); + config = makeFakeConfig(tmpDir.path); + }); + + afterEach(() => { + tmpDir.cleanupCallback(); + }); + + it('adds a SwiftUI-shaped manifest when none exists', async () => { + writeFileSync(plistPath, INFO_PLIST_8_4); + + const result = await setSwiftUISceneManifest(config); + + expect(result.status).toBe('written'); + const manifest = readManifest(plistPath); + expect(manifest['UIApplicationSupportsMultipleScenes']).toBe(false); + expect(manifest['UISceneConfigurations']).toBeUndefined(); + }); + + it('drops UIMainStoryboardFile', async () => { + writeFileSync(plistPath, INFO_PLIST_8_4); + + await setSwiftUISceneManifest(config); + + const parsed = parse(readFileSync(plistPath, 'utf-8')) as PlistObject; + expect(parsed['UIMainStoryboardFile']).toBeUndefined(); + }); + + it('preserves sibling keys', async () => { + writeFileSync(plistPath, INFO_PLIST_8_4); + + await setSwiftUISceneManifest(config); + + const parsed = parse(readFileSync(plistPath, 'utf-8')) as PlistObject; + expect(parsed['CFBundleDisplayName']).toBe('My App'); + expect(parsed['UILaunchStoryboardName']).toBe('LaunchScreen'); + }); + + it('rewrites an 8.5 manifest by dropping UISceneConfigurations', async () => { + writeFileSync(plistPath, INFO_PLIST_8_5); + + const result = await setSwiftUISceneManifest(config); + + expect(result.status).toBe('written'); + const manifest = readManifest(plistPath); + expect(manifest['UISceneConfigurations']).toBeUndefined(); + expect(manifest['UIApplicationSupportsMultipleScenes']).toBe(false); + }); + + it('preserves UIApplicationSupportsMultipleScenes when the project opted in', async () => { + writeFileSync(plistPath, INFO_PLIST_8_5.replace('', '')); + + await setSwiftUISceneManifest(config); + + expect(readManifest(plistPath)['UIApplicationSupportsMultipleScenes']).toBe(true); + }); + + it('refuses to touch a manifest naming a custom scene delegate', async () => { + writeFileSync(plistPath, INFO_PLIST_CUSTOM_SCENE_DELEGATE); + const before = readFileSync(plistPath, 'utf-8'); + + const result = await setSwiftUISceneManifest(config); + + expect(result.status).toBe('skipped'); + expect(result.status === 'skipped' && result.reason).toContain('CustomSceneDelegate'); + expect(readFileSync(plistPath, 'utf-8')).toBe(before); + }); + + it('reports unchanged for an already-migrated plist', async () => { + writeFileSync(plistPath, INFO_PLIST_9_0); + const before = readFileSync(plistPath, 'utf-8'); + + const result = await setSwiftUISceneManifest(config); + + expect(result.status).toBe('unchanged'); + expect(readFileSync(plistPath, 'utf-8')).toBe(before); + }); + + it('is idempotent across repeated runs', async () => { + writeFileSync(plistPath, INFO_PLIST_8_4); + + await setSwiftUISceneManifest(config); + const firstOutput = readFileSync(plistPath, 'utf-8'); + await setSwiftUISceneManifest(config); + const secondOutput = readFileSync(plistPath, 'utf-8'); + + expect(secondOutput).toBe(firstOutput); + }); + + it('skips when the plist does not exist', async () => { + const result = await setSwiftUISceneManifest(config); + + expect(result.status).toBe('skipped'); + expect(result.status === 'skipped' && result.reason).toContain('not found'); + }); +}); + +describe('hasSceneManifest', () => { + let tmpDir: any; + let plistPath: string; + let config: Config; + + beforeEach(async () => { + tmpDir = await mktmp(); + plistPath = join(tmpDir.path, 'Info.plist'); + config = makeFakeConfig(tmpDir.path); + }); + + afterEach(() => { + tmpDir.cleanupCallback(); + }); + + it('returns false when the manifest key is absent', () => { + writeFileSync(plistPath, INFO_PLIST_8_4); + expect(hasSceneManifest(config)).toBe(false); + }); + + it('returns true when the manifest key is present', () => { + writeFileSync(plistPath, INFO_PLIST_8_5); + expect(hasSceneManifest(config)).toBe(true); + }); + + it('returns false when the plist does not exist', () => { + expect(hasSceneManifest(config)).toBe(false); + }); +}); + +describe('hasSwiftUISceneManifest', () => { + let tmpDir: any; + let plistPath: string; + let config: Config; + + beforeEach(async () => { + tmpDir = await mktmp(); + plistPath = join(tmpDir.path, 'Info.plist'); + config = makeFakeConfig(tmpDir.path); + }); + + afterEach(() => { + tmpDir.cleanupCallback(); + }); + + it('returns false for an 8.4 plist with no manifest', () => { + writeFileSync(plistPath, INFO_PLIST_8_4); + expect(hasSwiftUISceneManifest(config)).toBe(false); + }); + + it('returns false for an 8.5 manifest with UISceneConfigurations', () => { + writeFileSync(plistPath, INFO_PLIST_8_5); + expect(hasSwiftUISceneManifest(config)).toBe(false); + }); + + it('returns true for a manifest without UISceneConfigurations', () => { + writeFileSync(plistPath, INFO_PLIST_9_0); + expect(hasSwiftUISceneManifest(config)).toBe(true); + }); + + it('returns false when the plist does not exist', () => { + expect(hasSwiftUISceneManifest(config)).toBe(false); + }); +}); diff --git a/cli/test/migrate-uiscene-scan.spec.ts b/cli/test/migrate-swiftui-scan.spec.ts similarity index 100% rename from cli/test/migrate-uiscene-scan.spec.ts rename to cli/test/migrate-swiftui-scan.spec.ts diff --git a/cli/test/migrate-uiscene-detect.spec.ts b/cli/test/migrate-uiscene-detect.spec.ts deleted file mode 100644 index 4c73819e2b..0000000000 --- a/cli/test/migrate-uiscene-detect.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { __testables } from '../src/tasks/migrate-uiscene'; - -const { classify } = __testables; - -describe('migrate-uiscene classify', () => { - it('returns eligible when nothing scene-related is present', () => { - expect( - classify({ - hasManifest: false, - hasSceneDelegate: false, - hasConfigurationForConnecting: false, - }), - ).toBe('eligible'); - }); - - it('returns already-migrated when all three signals are present', () => { - expect( - classify({ - hasManifest: true, - hasSceneDelegate: true, - hasConfigurationForConnecting: true, - }), - ).toBe('already-migrated'); - }); - - it.each([ - [{ hasManifest: true, hasSceneDelegate: false, hasConfigurationForConnecting: false }], - [{ hasManifest: false, hasSceneDelegate: true, hasConfigurationForConnecting: false }], - [{ hasManifest: false, hasSceneDelegate: false, hasConfigurationForConnecting: true }], - [{ hasManifest: true, hasSceneDelegate: true, hasConfigurationForConnecting: false }], - [{ hasManifest: true, hasSceneDelegate: false, hasConfigurationForConnecting: true }], - [{ hasManifest: false, hasSceneDelegate: true, hasConfigurationForConnecting: true }], - ])('returns partial for mixed signals %j', (signals) => { - expect(classify(signals)).toBe('partial'); - }); -}); diff --git a/cli/test/migrate-uiscene-plist.spec.ts b/cli/test/migrate-uiscene-plist.spec.ts deleted file mode 100644 index dc3bd25135..0000000000 --- a/cli/test/migrate-uiscene-plist.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { readFileSync, writeFileSync } from 'fs-extra'; -import { join } from 'path'; -import type { PlistObject } from 'plist'; -import { parse } from 'plist'; - -import type { Config } from '../src/definitions'; -import { addSceneManifestIfNeeded, hasSceneManifest } from '../src/util/spm'; - -import { mktmp } from './util'; - -const PRE_UISCENE_INFO_PLIST = ` - - - - CFBundleDisplayName - My App - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - - -`; - -const POST_UISCENE_INFO_PLIST = ` - - - - CFBundleDisplayName - My App - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Existing Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).CustomSceneDelegate - - - - - - -`; - -function makeFakeConfig(nativeTargetDirAbs: string): Config { - return { ios: { nativeTargetDirAbs } } as unknown as Config; -} - -describe('addSceneManifestIfNeeded', () => { - let tmpDir: any; - let plistPath: string; - let config: Config; - - beforeEach(async () => { - tmpDir = await mktmp(); - plistPath = join(tmpDir.path, 'Info.plist'); - config = makeFakeConfig(tmpDir.path); - }); - - afterEach(() => { - tmpDir.cleanupCallback(); - }); - - it('adds the scene manifest when absent', async () => { - writeFileSync(plistPath, PRE_UISCENE_INFO_PLIST); - - await addSceneManifestIfNeeded(config); - - const parsed = parse(readFileSync(plistPath, 'utf-8')) as PlistObject; - const manifest = parsed['UIApplicationSceneManifest'] as PlistObject; - expect(manifest['UIApplicationSupportsMultipleScenes']).toBe(false); - const configs = manifest['UISceneConfigurations'] as PlistObject; - const roleArray = configs['UIWindowSceneSessionRoleApplication'] as PlistObject[]; - expect(roleArray).toHaveLength(1); - expect(roleArray[0]['UISceneConfigurationName']).toBe('Default Configuration'); - expect(roleArray[0]['UISceneDelegateClassName']).toBe('$(PRODUCT_MODULE_NAME).SceneDelegate'); - expect(roleArray[0]['UISceneStoryboardFile']).toBe('Main'); - }); - - it('preserves sibling keys', async () => { - writeFileSync(plistPath, PRE_UISCENE_INFO_PLIST); - - await addSceneManifestIfNeeded(config); - - const parsed = parse(readFileSync(plistPath, 'utf-8')) as PlistObject; - expect(parsed['CFBundleDisplayName']).toBe('My App'); - expect(parsed['UILaunchStoryboardName']).toBe('LaunchScreen'); - expect(parsed['UIMainStoryboardFile']).toBe('Main'); - }); - - it('is a no-op when the manifest already exists and does not overwrite user data', async () => { - writeFileSync(plistPath, POST_UISCENE_INFO_PLIST); - - await addSceneManifestIfNeeded(config); - - const parsed = parse(readFileSync(plistPath, 'utf-8')) as PlistObject; - const manifest = parsed['UIApplicationSceneManifest'] as PlistObject; - const configs = manifest['UISceneConfigurations'] as PlistObject; - const roleArray = configs['UIWindowSceneSessionRoleApplication'] as PlistObject[]; - expect(roleArray[0]['UISceneConfigurationName']).toBe('Existing Configuration'); - expect(roleArray[0]['UISceneDelegateClassName']).toBe('$(PRODUCT_MODULE_NAME).CustomSceneDelegate'); - }); - - it('is idempotent across repeated runs', async () => { - writeFileSync(plistPath, PRE_UISCENE_INFO_PLIST); - - await addSceneManifestIfNeeded(config); - const firstOutput = readFileSync(plistPath, 'utf-8'); - await addSceneManifestIfNeeded(config); - const secondOutput = readFileSync(plistPath, 'utf-8'); - await addSceneManifestIfNeeded(config); - const thirdOutput = readFileSync(plistPath, 'utf-8'); - - expect(secondOutput).toBe(firstOutput); - expect(thirdOutput).toBe(firstOutput); - }); - - it('is a no-op when the plist does not exist', async () => { - // No write — plist file is missing. - await expect(addSceneManifestIfNeeded(config)).resolves.toBeUndefined(); - }); -}); - -describe('hasSceneManifest', () => { - let tmpDir: any; - let plistPath: string; - let config: Config; - - beforeEach(async () => { - tmpDir = await mktmp(); - plistPath = join(tmpDir.path, 'Info.plist'); - config = makeFakeConfig(tmpDir.path); - }); - - afterEach(() => { - tmpDir.cleanupCallback(); - }); - - it('returns false when the manifest key is absent', () => { - writeFileSync(plistPath, PRE_UISCENE_INFO_PLIST); - expect(hasSceneManifest(config)).toBe(false); - }); - - it('returns true when the manifest key is present', () => { - writeFileSync(plistPath, POST_UISCENE_INFO_PLIST); - expect(hasSceneManifest(config)).toBe(true); - }); - - it('returns false when the plist does not exist', () => { - expect(hasSceneManifest(config)).toBe(false); - }); -}); diff --git a/cli/test/migrate-uiscene-scene-delegate.spec.ts b/cli/test/migrate-uiscene-scene-delegate.spec.ts deleted file mode 100644 index b2f400588c..0000000000 --- a/cli/test/migrate-uiscene-scene-delegate.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { readFileSync } from 'fs-extra'; -import { resolve } from 'path'; - -import { __testables } from '../src/tasks/migrate-uiscene'; - -const { extractConfigurationForConnecting, insertBeforeAppDelegateClassEnd } = __testables; - -const REPO_ROOT = resolve(__dirname, '..', '..'); -const SHIPPED_APP_DELEGATE_SPM = resolve(REPO_ROOT, 'ios-spm-template/App/App/AppDelegate.swift'); -const SHIPPED_APP_DELEGATE_PODS = resolve(REPO_ROOT, 'ios-pods-template/App/App/AppDelegate.swift'); - -describe('extractConfigurationForConnecting', () => { - it('slices the method out of the shipped SPM AppDelegate', () => { - const shipped = readFileSync(SHIPPED_APP_DELEGATE_SPM, 'utf-8'); - const snippet = extractConfigurationForConnecting(shipped); - expect(snippet).not.toBeNull(); - expect(snippet).toContain('configurationForConnecting connectingSceneSession: UISceneSession'); - expect(snippet).toContain('UISceneConfiguration(name: "Default Configuration"'); - expect(snippet).toContain('config.delegateClass = SceneDelegate.self'); - expect(snippet!.startsWith('\n')).toBe(true); - expect(snippet!.endsWith('\n')).toBe(true); - }); - - it('slices the method out of the shipped Pods AppDelegate', () => { - const shipped = readFileSync(SHIPPED_APP_DELEGATE_PODS, 'utf-8'); - const snippet = extractConfigurationForConnecting(shipped); - expect(snippet).not.toBeNull(); - expect(snippet).toContain('configurationForConnecting connectingSceneSession: UISceneSession'); - expect(snippet).toContain('config.delegateClass = SceneDelegate.self'); - }); - - it('returns null when the method signature is absent', () => { - const source = `import UIKit\n\nclass AppDelegate: UIResponder, UIApplicationDelegate {\n var window: UIWindow?\n}\n`; - expect(extractConfigurationForConnecting(source)).toBeNull(); - }); -}); - -describe('insertBeforeAppDelegateClassEnd', () => { - it('turns a pre-UIScene AppDelegate into the shipped SPM AppDelegate', () => { - const shipped = readFileSync(SHIPPED_APP_DELEGATE_SPM, 'utf-8'); - const snippet = extractConfigurationForConnecting(shipped); - expect(snippet).not.toBeNull(); - const pre = stripConfigurationForConnecting(shipped); - - const patched = insertBeforeAppDelegateClassEnd(pre, snippet!); - - expect(patched).toBe(shipped); - }); - - it('returns null when no AppDelegate class is present', () => { - const source = `import UIKit\n\nstruct Something {}\n`; - expect(insertBeforeAppDelegateClassEnd(source, '\n func x() {}\n')).toBeNull(); - }); - - it('returns null when class braces are unbalanced', () => { - const source = `class AppDelegate: UIResponder {\n func foo() {\n`; - expect(insertBeforeAppDelegateClassEnd(source, '\n func x() {}\n')).toBeNull(); - }); - - it('inserts inside the class, before its closing brace, even with a trailing extension', () => { - const source = [ - 'class AppDelegate: UIResponder, UIApplicationDelegate {', - ' func a() {}', - '}', - '', - 'extension AppDelegate {', - ' func b() {}', - '}', - '', - ].join('\n'); - - const patched = insertBeforeAppDelegateClassEnd(source, '\n func injected() {}\n'); - - expect(patched).toBe( - [ - 'class AppDelegate: UIResponder, UIApplicationDelegate {', - ' func a() {}', - '', - ' func injected() {}', - '}', - '', - 'extension AppDelegate {', - ' func b() {}', - '}', - '', - ].join('\n'), - ); - }); -}); - -function stripConfigurationForConnecting(source: string): string { - // Remove the configurationForConnecting method + its leading blank line from the shipped template, - // yielding an 8.4-shaped AppDelegate for use as a fixture. - return source.replace( - /\n\n {4}func application\(_ application: UIApplication,\n {21}configurationForConnecting[\s\S]*?\n {4}\}\n\}/, - '\n}', - ); -} diff --git a/cli/test/xcode.spec.ts b/cli/test/xcode.spec.ts index e6aa083570..9e0a7788b0 100644 --- a/cli/test/xcode.spec.ts +++ b/cli/test/xcode.spec.ts @@ -9,10 +9,10 @@ import { mktmp } from './util'; const REPO_ROOT = resolve(__dirname, '..', '..'); const SHIPPED_PBXPROJ = resolve(REPO_ROOT, 'ios-spm-template/App/App.xcodeproj/project.pbxproj'); -function stripSceneDelegate(source: string): string { +function stripCapacitorView(source: string): string { return source .split('\n') - .filter((line) => !line.includes('SceneDelegate')) + .filter((line) => !line.includes('CapacitorView')) .join('\n'); } @@ -51,10 +51,10 @@ describe('addSwiftFileToAppTarget', () => { }); it('registers a new Swift file in all four pbxproj sections', () => { - const preUISceneSource = stripSceneDelegate(readFileSync(SHIPPED_PBXPROJ, 'utf-8')); - writeFileSync(pbxprojPath, preUISceneSource); + const preMigrationSource = stripCapacitorView(readFileSync(SHIPPED_PBXPROJ, 'utf-8')); + writeFileSync(pbxprojPath, preMigrationSource); - const result = addSwiftFileToAppTarget(pbxprojPath, 'App', 'SceneDelegate.swift'); + const result = addSwiftFileToAppTarget(pbxprojPath, 'App', 'CapacitorView.swift'); expect(result.added).toBe(true); @@ -65,30 +65,30 @@ describe('addSwiftFileToAppTarget', () => { const objects = project.hash.project.objects; const fileRefs = Object.entries(objects.PBXFileReference).filter(([k]) => !k.endsWith('_comment')); - expect(fileRefs.some(([, ref]) => typeof ref === 'object' && (ref as any).path === '"SceneDelegate.swift"')).toBe( + expect(fileRefs.some(([, ref]) => typeof ref === 'object' && (ref as any).path === '"CapacitorView.swift"')).toBe( true, ); const buildFiles = Object.entries(objects.PBXBuildFile).filter(([k]) => !k.endsWith('_comment')); expect( - buildFiles.some(([k]) => (objects.PBXBuildFile as any)[`${k}_comment`]?.includes('SceneDelegate.swift')), + buildFiles.some(([k]) => (objects.PBXBuildFile as any)[`${k}_comment`]?.includes('CapacitorView.swift')), ).toBe(true); const appGroupUuid = findGroupUuidByComment(project, 'App')!; const appGroup = project.getPBXGroupByKey(appGroupUuid)!; - expect(appGroup.children.some((c: any) => c.comment === 'SceneDelegate.swift')).toBe(true); + expect(appGroup.children.some((c: any) => c.comment === 'CapacitorView.swift')).toBe(true); const sourcesPhase = objects.PBXSourcesBuildPhase!; const sourcesEntries = Object.entries(sourcesPhase).filter(([k]) => !k.endsWith('_comment')); const [, sourcesObj] = sourcesEntries[0]; - expect((sourcesObj as any).files.some((f: any) => f.comment?.includes('SceneDelegate.swift'))).toBe(true); + expect((sourcesObj as any).files.some((f: any) => f.comment?.includes('CapacitorView.swift'))).toBe(true); }); it('is a no-op when the file is already registered', () => { writeFileSync(pbxprojPath, readFileSync(SHIPPED_PBXPROJ, 'utf-8')); const before = readFileSync(pbxprojPath, 'utf-8'); - const result = addSwiftFileToAppTarget(pbxprojPath, 'App', 'SceneDelegate.swift'); + const result = addSwiftFileToAppTarget(pbxprojPath, 'App', 'CapacitorView.swift'); const after = readFileSync(pbxprojPath, 'utf-8'); expect(result.added).toBe(false); @@ -96,18 +96,18 @@ describe('addSwiftFileToAppTarget', () => { }); it('is idempotent across repeated runs', () => { - const preUISceneSource = stripSceneDelegate(readFileSync(SHIPPED_PBXPROJ, 'utf-8')); - writeFileSync(pbxprojPath, preUISceneSource); + const preMigrationSource = stripCapacitorView(readFileSync(SHIPPED_PBXPROJ, 'utf-8')); + writeFileSync(pbxprojPath, preMigrationSource); - expect(addSwiftFileToAppTarget(pbxprojPath, 'App', 'SceneDelegate.swift').added).toBe(true); - expect(addSwiftFileToAppTarget(pbxprojPath, 'App', 'SceneDelegate.swift').added).toBe(false); - expect(addSwiftFileToAppTarget(pbxprojPath, 'App', 'SceneDelegate.swift').added).toBe(false); + expect(addSwiftFileToAppTarget(pbxprojPath, 'App', 'CapacitorView.swift').added).toBe(true); + expect(addSwiftFileToAppTarget(pbxprojPath, 'App', 'CapacitorView.swift').added).toBe(false); + expect(addSwiftFileToAppTarget(pbxprojPath, 'App', 'CapacitorView.swift').added).toBe(false); }); it('throws when the target group cannot be found', () => { - writeFileSync(pbxprojPath, stripSceneDelegate(readFileSync(SHIPPED_PBXPROJ, 'utf-8'))); + writeFileSync(pbxprojPath, stripCapacitorView(readFileSync(SHIPPED_PBXPROJ, 'utf-8'))); - expect(() => addSwiftFileToAppTarget(pbxprojPath, 'DoesNotExist', 'SceneDelegate.swift')).toThrow( + expect(() => addSwiftFileToAppTarget(pbxprojPath, 'DoesNotExist', 'CapacitorView.swift')).toThrow( /Could not find PBXGroup/, ); }); From 748cc536f8b74d352046d99c87bdb3b2bcb916a5 Mon Sep 17 00:00:00 2001 From: Joseph Pender Date: Mon, 14 Sep 2026 09:49:50 -0500 Subject: [PATCH 4/5] rename UIScene migration symbols to SwiftUI --- cli/src/tasks/migrate-swiftui.ts | 24 +++++++++---------- cli/src/tasks/migrate.ts | 4 ++-- cli/test/migrate-swiftui-app-delegate.spec.ts | 2 +- cli/test/migrate-swiftui-detect.spec.ts | 6 ++--- cli/test/migrate-swiftui-scan.spec.ts | 2 +- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cli/src/tasks/migrate-swiftui.ts b/cli/src/tasks/migrate-swiftui.ts index a8289af46d..21ca915031 100644 --- a/cli/src/tasks/migrate-swiftui.ts +++ b/cli/src/tasks/migrate-swiftui.ts @@ -9,9 +9,9 @@ import { hasSwiftUISceneManifest, setSwiftUISceneManifest } from '../util/spm'; import { extractTemplate } from '../util/template'; import { addSwiftFileToAppTarget } from '../util/xcode'; -type PreUISceneState = 'eligible' | 'already-migrated' | 'partial'; +type MigrationState = 'eligible' | 'already-migrated' | 'partial'; -interface UISceneDetectionSignals { +interface SwiftUIDetectionSignals { hasSwiftUIManifest: boolean; hasAppStruct: boolean; hasCapacitorView: boolean; @@ -29,17 +29,17 @@ const OPEN_URL_SIG = /func application\([^)]*\bopen url:/; const CONTINUE_SIG = /func application\([^)]*\bcontinue userActivity:/; const CONFIGURATION_FOR_CONNECTING_SIG = /func application\([^)]*\bconfigurationForConnecting\b/; -export async function migrateToUIScene(config: Config): Promise { +export async function migrateToSwiftUI(config: Config): Promise { const signals = readDetectionSignals(config); const state = classify(signals); switch (state) { case 'already-migrated': - logger.info('UIScene migration: project already uses the SwiftUI App-struct layout, skipping.'); + logger.info('SwiftUI migration: project already uses the SwiftUI App-struct layout, skipping.'); return; case 'partial': logger.warn( - `UIScene migration: project is in a partial state (${describeSignals(signals)}). ` + + `SwiftUI migration: project is in a partial state (${describeSignals(signals)}). ` + `Skipping automated migration — finish the migration by hand or reset the iOS project to a clean 8.4/8.5 state first.`, ); return; @@ -50,14 +50,14 @@ export async function migrateToUIScene(config: Config): Promise { const appDelegatePath = join(config.ios.nativeTargetDirAbs, 'AppDelegate.swift'); const rewrite = planAppDelegateRewrite(appDelegatePath); if (rewrite.status === 'skipped') { - logger.warn(`UIScene migration: skipping automated migration — ${rewrite.reason}`); + logger.warn(`SwiftUI migration: skipping automated migration — ${rewrite.reason}`); printManualSteps(); return; } const assets = await loadTemplateAssets(config); if (!assets) { - logger.error('UIScene migration: could not read shipped iOS template assets; skipping.'); + logger.error('SwiftUI migration: could not read shipped iOS template assets; skipping.'); return; } @@ -185,7 +185,7 @@ function hasCustomWindowSetup(source: string): boolean { function printNextSteps(config: Config): void { logger.info(''); - logger.info('UIScene migration next steps:'); + logger.info('SwiftUI migration next steps:'); logger.info(' • Review any warnings above for legacy API usage or custom AppDelegate URL/activity handlers.'); logger.info(' • App.swift now owns the app entry point; move UIKit root-window customizations into its scene body.'); for (const leftover of leftoverUIKitFiles(config)) { @@ -219,7 +219,7 @@ async function loadTemplateAssets(config: Config): Promise { +describe('migrate-swiftui classify', () => { it('returns eligible for a project with none of the SwiftUI App-struct markers', () => { expect(classify(NOTHING_MIGRATED)).toBe('eligible'); }); @@ -34,7 +34,7 @@ describe('migrate-uiscene classify', () => { }); }); -describe('migrate-uiscene describeSignals', () => { +describe('migrate-swiftui describeSignals', () => { it('reports every marker as missing for an unmigrated project', () => { const description = describeSignals(NOTHING_MIGRATED); diff --git a/cli/test/migrate-swiftui-scan.spec.ts b/cli/test/migrate-swiftui-scan.spec.ts index c0d7964ddf..24247c20fd 100644 --- a/cli/test/migrate-swiftui-scan.spec.ts +++ b/cli/test/migrate-swiftui-scan.spec.ts @@ -3,7 +3,7 @@ import { join } from 'path'; import type { Config } from '../src/definitions'; import { logger } from '../src/log'; -import { __testables } from '../src/tasks/migrate-uiscene'; +import { __testables } from '../src/tasks/migrate-swiftui'; import { mktmp } from './util'; From d7a773cf4ae9b63169b9096c28a52a82e7be50ed Mon Sep 17 00:00:00 2001 From: Joseph Pender Date: Thu, 17 Sep 2026 16:22:37 -0500 Subject: [PATCH 5/5] fmt --- cli/src/tasks/migrate.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/src/tasks/migrate.ts b/cli/src/tasks/migrate.ts index a9c33494de..70af974ee1 100644 --- a/cli/src/tasks/migrate.ts +++ b/cli/src/tasks/migrate.ts @@ -13,9 +13,8 @@ import { deleteFolderRecursive } from '../util/fs'; import { runCommand } from '../util/subprocess'; import { extractTemplate } from '../util/template'; -import { migrateToSwiftUI } from './migrate-swiftui'; import { migrateToEdgeToEdge } from './migrate-edge-to-edge'; -import { migrateToUIScene } from './migrate-uiscene'; +import { migrateToSwiftUI } from './migrate-swiftui'; // eslint-disable-next-line prefer-const let allDependencies: { [key: string]: any } = {};