diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a65eb402..afd7baee7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -663,8 +663,20 @@ public struct CapacitorView: UIViewControllerRepresentable { public func makeUIViewController(context: Context) -> CAPBridgeViewController { CAPBridgeViewController() } public func updateUIViewController(_ vc: CAPBridgeViewController, context: Context) {} } + +// SceneDelegate.swift — deliberately empty +class SceneDelegate: UIResponder, UIWindowSceneDelegate {} ``` +`SceneDelegate` carries no methods. It exists because `UISceneConfiguration.delegateClass` +takes a type and UIKit instantiates it per scene, so there is no way to hand UIKit +`SceneDelegateProxy.shared` — the app target has to own a concrete class for `AppDelegate` +to name from `application(_:configurationForConnecting:options:)`. It is the install point +for per-scene UIKit APIs SwiftUI does not surface, the first of which is +`preferredWindowingControlStyle(for:)` for the iPadOS 26 window controls. It stays empty of +routing on purpose: a scene delegate that implements `scene(_:openURLContexts:)` takes URL +delivery away from `.onOpenURL` below. + `CapacitorView` lives in the app target, not the runtime. An app that needs its own `CAPBridgeViewController` subclass (custom `webViewConfiguration(for:)`, `router()`, `capacitorDidLoad()`) returns that subclass from `makeUIViewController`. This replaces @@ -1370,7 +1382,7 @@ rather than fail when a project has been customised beyond recognition. | Task | Command | What it rewrites | | --- | --- | --- | | `migrate.ts` | `cap migrate` | The umbrella migrator. Bumps `@capacitor/*` and known official plugins in `package.json` and installs them; updates the Gradle wrapper, root and app `build.gradle`, `gradle.properties` and `variables.gradle`; removes `jcenter()`; patches `AndroidManifest.xml` and `AppDelegate.swift`; checks the JDK major; prints the breaking-change list. Delegates to the two below. | -| `migrate-swiftui.ts` | (from `cap migrate`) | Converts a storyboard/`SceneDelegate` iOS app to the SwiftUI App-struct layout: rewrites `AppDelegate.swift` into a `@UIApplicationDelegateAdaptor`, writes `App.swift` and `CapacitorView.swift`, repoints `Info.plist` at the SwiftUI scene setup, and registers the new files with the Xcode App target via `addSwiftFileToAppTarget` (`cli/src/util/xcode.ts`). Detects partial state and skips rather than half-applying. | +| `migrate-swiftui.ts` | (from `cap migrate`) | Converts a storyboard iOS app to the SwiftUI App-struct layout: rewrites `AppDelegate.swift` into a `@UIApplicationDelegateAdaptor` (keeping or adding `configurationForConnecting`), writes `App.swift`, `CapacitorView.swift` and `SceneDelegate.swift`, repoints `Info.plist` at the SwiftUI scene setup, and registers the new files with the Xcode App target via `addSwiftFileToAppTarget` (`cli/src/util/xcode.ts`). A stock 8.x `SceneDelegate.swift` is replaced with the 9.0 one; a customised one is kept with a warning. Detects partial state and skips rather than half-applying. | | `migrate-edge-to-edge.ts` | (from `cap migrate`) | Adds `EdgeToEdge.enable(this)` / `enableEdgeToEdge()` plus the needed imports to the app's `MainActivity` (Java or Kotlin). Skips when `plugins.SystemBars.insetsHandling` is `'disable'` or the activity already opts in; warns with manual instructions if it cannot patch the file. | | `migrate-spm.ts` | `cap spm-migration-assistant` | Moves an iOS app from CocoaPods to SwiftPM: extracts the `CapApp-SPM` package directory from the bundled template and deletes `Podfile`, `Podfile.lock` and `App.xcworkspace`. | diff --git a/cli/src/tasks/migrate-swiftui.ts b/cli/src/tasks/migrate-swiftui.ts index 21ca91503..a49ed6e97 100644 --- a/cli/src/tasks/migrate-swiftui.ts +++ b/cli/src/tasks/migrate-swiftui.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs-extra'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs-extra'; import { join, sep } from 'path'; import { runTask } from '../common'; @@ -7,44 +7,94 @@ 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'; +import { addSwiftFileToAppTarget, removeStoryboardFromAppTarget } from '../util/xcode'; -type MigrationState = 'eligible' | 'already-migrated' | 'partial'; +type MigrationState = 'eligible' | 'already-migrated'; interface SwiftUIDetectionSignals { hasSwiftUIManifest: boolean; hasAppStruct: boolean; hasCapacitorView: boolean; hasDelegateAdaptorShape: boolean; + hasSceneDelegate: boolean; } interface TemplateAssets { app: string; + appDelegate: string; capacitorView: string; + sceneDelegate: string; } type AppDelegateRewrite = { status: 'rewritten'; source: string } | { status: 'skipped'; reason: string }; +const MIGRATION_GUIDE_URL = 'https://capacitorjs.com/docs/next/updating/9-0'; + const OPEN_URL_SIG = /func application\([^)]*\bopen url:/; const CONTINUE_SIG = /func application\([^)]*\bcontinue userActivity:/; const CONFIGURATION_FOR_CONNECTING_SIG = /func application\([^)]*\bconfigurationForConnecting\b/; +// Kept byte-identical to the shipped templates; migrate-swiftui-stock-app-delegate.spec.ts +// fails if the two drift apart. +const CONFIGURATION_FOR_CONNECTING_METHOD = [ + ' func application(', + ' _ application: UIApplication,', + ' configurationForConnecting connectingSceneSession: UISceneSession,', + ' options: UIScene.ConnectionOptions', + ' ) -> UISceneConfiguration {', + ' let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)', + ' configuration.delegateClass = SceneDelegate.self', + ' return configuration', + ' }', +].join('\n'); + +const SCENE_WILL_CONNECT_SIG = /func scene\([^)]*\bwillConnectTo session:/; +const SCENE_OPEN_URL_CONTEXTS_SIG = /func scene\([^)]*\bopenURLContexts\b/; +const SCENE_CONTINUE_SIG = /func scene\([^)]*\bcontinue userActivity:/; +const SCENE_DELEGATE_PROXY = 'SceneDelegateProxy.shared'; +const STOCK_WILL_CONNECT_BODY = [ + /^guard let windowScene = scene as\? UIWindowScene else \{ return \}$/, + /^window = UIWindow\(windowScene: windowScene\)$/, + /^window\?\.rootViewController = CAPBridgeViewController\(\)$/, + /^window\?\.makeKeyAndVisible\(\)$/, + /^SceneDelegateProxy\.shared\.scene\(/, +]; +const STOCK_WINDOW_PROPERTY = /^var\s+window\s*:\s*UIWindow\?$/; + +const APP_DELEGATE_CLASS_SIG = /\bclass\s+AppDelegate\s*:\s*(?:UIResponder|NSObject)\s*,\s*UIApplicationDelegate\s*\{/; +const DID_FINISH_LAUNCHING_SIG = /func application\([^)]*\bdidFinishLaunchingWithOptions\b/; +const APPLICATION_DELEGATE_PROXY = 'ApplicationDelegateProxy.shared'; +// Every module a stock AppDelegate has ever imported; anything else is the developer's. +const STOCK_APP_DELEGATE_PREAMBLE = /^(?:import\s+(?:UIKit|Capacitor|Foundation)|@(?:main|UIApplicationMain))$/; + +/** + * The members every stock AppDelegate has carried since Capacitor 5, with the body each is + * allowed to hold. `configurationForConnecting` takes any body: whatever it names as the + * delegate class, the 9.0 template replaces it with one naming SceneDelegate. + */ +const STOCK_APP_DELEGATE_METHODS: { sig: RegExp; bodyIsStock: (lines: string[]) => boolean }[] = [ + { sig: DID_FINISH_LAUNCHING_SIG, bodyIsStock: (lines) => lines.length === 1 && lines[0] === 'return true' }, + { sig: /func applicationWillResignActive\(/, bodyIsStock: isEmpty }, + { sig: /func applicationDidEnterBackground\(/, bodyIsStock: isEmpty }, + { sig: /func applicationWillEnterForeground\(/, bodyIsStock: isEmpty }, + { sig: /func applicationDidBecomeActive\(/, bodyIsStock: isEmpty }, + { sig: /func applicationWillTerminate\(/, bodyIsStock: isEmpty }, + { sig: OPEN_URL_SIG, bodyIsStock: (lines) => lines.every((line) => line.includes(APPLICATION_DELEGATE_PROXY)) }, + { sig: CONTINUE_SIG, bodyIsStock: (lines) => lines.every((line) => line.includes(APPLICATION_DELEGATE_PROXY)) }, + { sig: CONFIGURATION_FOR_CONNECTING_SIG, bodyIsStock: () => true }, +]; export async function migrateToSwiftUI(config: Config): Promise { const signals = readDetectionSignals(config); - const state = classify(signals); - switch (state) { - case 'already-migrated': - logger.info('SwiftUI migration: project already uses the SwiftUI App-struct layout, skipping.'); - return; - case 'partial': - logger.warn( - `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; - case 'eligible': - break; + if (classify(signals) === 'already-migrated') { + logger.info('SwiftUI migration: project already uses the SwiftUI App-struct layout, skipping.'); + return; + } + if (hasAnySignal(signals)) { + logger.info( + `SwiftUI migration: resuming a partially migrated project (${describeSignals(signals)}); ` + + `completing the missing steps.`, + ); } const appDelegatePath = join(config.ios.nativeTargetDirAbs, 'AppDelegate.swift'); @@ -68,7 +118,7 @@ export async function migrateToSwiftUI(config: Config): Promise { } }); - await runTask('Writing App.swift and CapacitorView.swift.', async () => { + await runTask('Writing App.swift, CapacitorView.swift and SceneDelegate.swift.', async () => { const files: [string, string][] = [ ['App.swift', assets.app], ['CapacitorView.swift', assets.capacitorView], @@ -78,21 +128,40 @@ export async function migrateToSwiftUI(config: Config): Promise { logger.warn(`${name} already exists, skipping.`); } } + + const sceneDelegatePath = join(config.ios.nativeTargetDirAbs, 'SceneDelegate.swift'); + if (writeSceneDelegate(sceneDelegatePath, assets.sceneDelegate) === 'kept') { + logger.warn( + `${sceneDelegatePath} carries code of your own, so it was kept. It is still the app's scene delegate, but ` + + `the SwiftUI App struct now owns the window: move any window setup out of it, and route ` + + `scene(_:openURLContexts:) and scene(_:continue:) through SceneDelegateProxy.shared from the SwiftUI ` + + `scene body instead. See ${MIGRATION_GUIDE_URL}.`, + ); + } }); + const replaceAppDelegate = isStockAppDelegate(readFileSync(appDelegatePath, 'utf-8')); await runTask('Converting AppDelegate.swift into a UIApplicationDelegateAdaptor.', async () => { - writeFileSync(appDelegatePath, rewrite.source); + writeFileSync(appDelegatePath, replaceAppDelegate ? assets.appDelegate : rewrite.source); }); + if (!replaceAppDelegate) { + logger.warn( + `${appDelegatePath} carries code of your own, so it was converted in place rather than replaced with the ` + + `slimmed-down Capacitor 9 template. Review what it still holds against ${MIGRATION_GUIDE_URL}.`, + ); + } - await runTask('Registering App.swift and CapacitorView.swift with the Xcode App target.', async () => { + let registrationFailed = false; + await runTask('Registering the SwiftUI entry-point files with the Xcode App target.', async () => { const pbxprojPath = join(config.ios.nativeXcodeProjDirAbs, 'project.pbxproj'); - for (const name of ['App.swift', 'CapacitorView.swift']) { + for (const name of ['App.swift', 'CapacitorView.swift', 'SceneDelegate.swift']) { try { const { added } = addSwiftFileToAppTarget(pbxprojPath, 'App', name); if (!added) { logger.warn(`${name} is already registered in the App target, skipping.`); } } catch (err: any) { + registrationFailed = true; logger.warn( `Could not register ${name} automatically: ${err?.message ?? err}. ` + `Add ${name} to the App target in Xcode manually.`, @@ -101,8 +170,65 @@ export async function migrateToSwiftUI(config: Config): Promise { } }); + if (registrationFailed) { + logger.warn('Leaving Main.storyboard in place because the App target was not fully updated.'); + } else { + await runTask('Removing the leftover main storyboard.', async () => { + removeLeftoverStoryboard(config); + }); + } + await scanAndWarn(config); - printNextSteps(config); + printNextSteps(); +} + +/** + * Put the 9.0 scene delegate in place. + * + * The class stays in the app target rather than moving into the framework because + * UISceneConfiguration.delegateClass takes a type and UIKit instantiates it itself, so the + * app has to own a concrete class for AppDelegate to name. A stock 8.x file is replaced + * outright: its window setup belongs to the SwiftUI App struct now. + */ +function writeSceneDelegate(path: string, contents: string): 'written' | 'replaced' | 'kept' { + if (!existsSync(path)) { + writeFileSync(path, contents); + return 'written'; + } + if (!isStockSceneDelegate(readFileSync(path, 'utf-8'))) { + return 'kept'; + } + writeFileSync(path, contents); + return 'replaced'; +} + +/** + * Drop the storyboard the SwiftUI App struct replaces. + * + * Deregisters before unlinking so a pbxproj that cannot be rewritten leaves a working + * project behind rather than a dangling reference. LaunchScreen.storyboard stays: Apple + * still requires a launch storyboard. + */ +function removeLeftoverStoryboard(config: Config): void { + const pbxprojPath = join(config.ios.nativeXcodeProjDirAbs, 'project.pbxproj'); + + const mainStoryboardPath = join(config.ios.nativeTargetDirAbs, 'Base.lproj', 'Main.storyboard'); + if (existsSync(mainStoryboardPath)) { + removeFile(pbxprojPath, mainStoryboardPath, () => removeStoryboardFromAppTarget(pbxprojPath, 'Main.storyboard')); + } +} + +function removeFile(pbxprojPath: string, path: string, deregister: () => void): void { + try { + deregister(); + } catch (err: any) { + logger.warn( + `Could not deregister ${path} from ${pbxprojPath}: ${err?.message ?? err}. ` + + `Delete it from the App target in Xcode manually.`, + ); + return; + } + unlinkSync(path); } async function scanAndWarn(config: Config): Promise { @@ -158,20 +284,91 @@ async function scanAndWarn(config: Config): Promise { } } -function hasCustomDelegateBody(source: string, sigRegex: RegExp): boolean { +function hasCustomDelegateBody( + source: string, + sigRegex: RegExp, + proxyToken = 'ApplicationDelegateProxy.shared', +): boolean { + const body = bracedBody(source, sigRegex); + if (body === null) return false; + return codeLines(body).some((line) => !line.includes(proxyToken)); +} + +/** + * Whether SceneDelegate.swift still holds exactly what the 8.x template shipped, and so + * can be deleted without losing anything the developer wrote. + */ +function isStockSceneDelegate(source: string): boolean { + const classBody = bracedBody(source, /\bclass\s+SceneDelegate\b/); + if (classBody === null) return false; + + const willConnectBody = bracedBody(classBody, SCENE_WILL_CONNECT_SIG); + if ( + willConnectBody !== null && + !codeLines(willConnectBody).every((line) => STOCK_WILL_CONNECT_BODY.some((stock) => stock.test(line))) + ) { + return false; + } + if (hasCustomDelegateBody(classBody, SCENE_OPEN_URL_CONTEXTS_SIG, SCENE_DELEGATE_PROXY)) return false; + if (hasCustomDelegateBody(classBody, SCENE_CONTINUE_SIG, SCENE_DELEGATE_PROXY)) return false; + + let members: string | null = classBody; + for (const sig of [SCENE_WILL_CONNECT_SIG, SCENE_OPEN_URL_CONTEXTS_SIG, SCENE_CONTINUE_SIG]) { + members = removeMethod(members, sig); + if (members === null) return false; + } + return codeLines(members).every((line) => STOCK_WINDOW_PROPERTY.test(line)); +} + +/** + * Whether AppDelegate.swift still holds exactly what a Capacitor template shipped, across + * every stock shape from 5.x to 8.x, and so can be replaced outright with the 9.0 template + * rather than surgically rewritten. + * + * The 9.0 template keeps only didFinishLaunchingWithOptions: the empty lifecycle stubs are + * noise, and URL and universal-link routing moved to the SwiftUI scene body, which + * App.swift wires to SceneDelegateProxy in the same run. + */ +function isStockAppDelegate(source: string): boolean { + if (!APP_DELEGATE_CLASS_SIG.test(source)) return false; + + const classBody = bracedBody(source, APP_DELEGATE_CLASS_SIG); + if (classBody === null) return false; + + const outsideClass = source.slice(0, source.search(APP_DELEGATE_CLASS_SIG)); + if (!codeLines(outsideClass).every((line) => STOCK_APP_DELEGATE_PREAMBLE.test(line))) { + return false; + } + + let members: string | null = classBody; + for (const { sig, bodyIsStock } of STOCK_APP_DELEGATE_METHODS) { + const body = bracedBody(members, sig); + if (body !== null && !bodyIsStock(codeLines(body))) return false; + members = removeMethod(members, sig); + if (members === null) return false; + } + return codeLines(members).every((line) => STOCK_WINDOW_PROPERTY.test(line)); +} + +function isEmpty(lines: string[]): boolean { + return lines.length === 0; +} + +function bracedBody(source: string, sigRegex: RegExp): string | null { const match = source.match(sigRegex); - if (!match || match.index === undefined) return false; + if (!match || match.index === undefined) return null; const openIdx = source.indexOf('{', match.index); - if (openIdx === -1) return false; + if (openIdx === -1) return null; const closeIdx = findMatchingBrace(source, openIdx); - if (closeIdx === null) return false; - const body = source.slice(openIdx + 1, closeIdx); - const codeLines = body + if (closeIdx === null) return null; + return source.slice(openIdx + 1, closeIdx); +} + +function codeLines(body: string): string[] { + return 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')); + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('//')); } function hasCustomWindowSetup(source: string): boolean { @@ -183,36 +380,27 @@ function hasCustomWindowSetup(source: string): boolean { }); } -function printNextSteps(config: Config): void { +function printNextSteps(): void { logger.info(''); 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)) { - 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'); + logger.info(` • Full guide: ${MIGRATION_GUIDE_URL}`); } 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(' • Drop @main and the UIWindow property from AppDelegate.'); + logger.info(' • Add SceneDelegate.swift (an empty UIWindowSceneDelegate) to the App target, and keep or add'); + logger.info(' configurationForConnecting in AppDelegate naming it as the scene delegate class.'); 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)); + logger.info(` • Full guide: ${MIGRATION_GUIDE_URL}`); } async function loadTemplateAssets(config: Config): Promise { @@ -224,13 +412,18 @@ async function loadTemplateAssets(config: Config): Promise !existsSync(path))) { return null; } return { app: readFileSync(appPath, 'utf-8'), + appDelegate: readFileSync(appDelegatePath, 'utf-8'), capacitorView: readFileSync(capacitorViewPath, 'utf-8'), + sceneDelegate: readFileSync(sceneDelegatePath, 'utf-8'), }; } finally { deleteFolderRecursive(tempDir); @@ -283,12 +476,51 @@ function rewriteAppDelegateForAdaptor(source: string): AppDelegateRewrite { 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.' }; + // UIKit stops calling these two once the app is scene-based, so leaving them behind + // would plant dead code a developer could mistake for live routing. Both bodies are + // known to hold nothing but ApplicationDelegateProxy calls by the guard above; App.swift + // re-routes them through SceneDelegateProxy in the same run. + const sigs = [OPEN_URL_SIG, CONTINUE_SIG]; + const names = ['application(_:open:)', 'application(_:continue:)']; + let stripped: string | null = rewritten; + for (const [index, sig] of sigs.entries()) { + stripped = removeMethod(stripped, sig); + if (stripped === null) { + return { status: 'skipped', reason: `could not parse the ${names[index]} method in AppDelegate.swift.` }; + } } - return { status: 'rewritten', source: withoutSceneConfiguration }; + const wired = ensureConfigurationForConnecting(stripped); + if (wired === null) { + return { status: 'skipped', reason: 'could not find the end of the AppDelegate class in AppDelegate.swift.' }; + } + + return { status: 'rewritten', source: wired }; +} + +/** + * Give the AppDelegate the scene configuration that names SceneDelegate. + * + * An 8.5 project already has one and keeps it — the class name it points at has not + * changed. Anything older gains the 9.0 template's version, because without it UIKit + * builds the scene with no delegate of ours and nothing can answer per-scene callbacks. + */ +function ensureConfigurationForConnecting(source: string): string | null { + // Located even when the method is already there: an unbalanced class body used to be + // caught by the strip this replaced, and reporting success on a file we cannot parse + // is worse than refusing it. + const match = source.match(/\bclass\s+AppDelegate\b/); + if (!match || match.index === undefined) return null; + const openIdx = source.indexOf('{', match.index); + if (openIdx === -1) return null; + const closeIdx = findMatchingBrace(source, openIdx); + if (closeIdx === null) return null; + + if (CONFIGURATION_FOR_CONNECTING_SIG.test(source)) { + return source; + } + + return `${source.slice(0, closeIdx)}\n${CONFIGURATION_FOR_CONNECTING_METHOD}\n${source.slice(closeIdx)}`; } function stripMainAttribute(source: string): string { @@ -342,6 +574,7 @@ function readDetectionSignals(config: Config): SwiftUIDetectionSignals { const appStructPath = join(config.ios.nativeTargetDirAbs, 'App.swift'); const capacitorViewPath = join(config.ios.nativeTargetDirAbs, 'CapacitorView.swift'); const appDelegatePath = join(config.ios.nativeTargetDirAbs, 'AppDelegate.swift'); + const sceneDelegatePath = join(config.ios.nativeTargetDirAbs, 'SceneDelegate.swift'); return { hasSwiftUIManifest: hasSwiftUISceneManifest(config), @@ -349,17 +582,35 @@ function readDetectionSignals(config: Config): SwiftUIDetectionSignals { hasCapacitorView: existsSync(capacitorViewPath), hasDelegateAdaptorShape: existsSync(appDelegatePath) && !/@(?:main|UIApplicationMain)\b/.test(readFileSync(appDelegatePath, 'utf-8')), + // A project migrated before the scene delegate landed has every other marker but no + // SceneDelegate.swift, so it reads as eligible and the next run completes it. + hasSceneDelegate: + existsSync(sceneDelegatePath) && + existsSync(appDelegatePath) && + CONFIGURATION_FOR_CONNECTING_SIG.test(readFileSync(appDelegatePath, 'utf-8')), }; } +/** + * Anything short of all five signals is eligible, not refused: every step below is + * individually idempotent, so a run interrupted part-way is finished by running again. + */ function classify(signals: SwiftUIDetectionSignals): MigrationState { - 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'; + return signalValues(signals).every(Boolean) ? 'already-migrated' : 'eligible'; +} + +function hasAnySignal(signals: SwiftUIDetectionSignals): boolean { + return signalValues(signals).some(Boolean); +} + +function signalValues({ + hasSwiftUIManifest, + hasAppStruct, + hasCapacitorView, + hasDelegateAdaptorShape, + hasSceneDelegate, +}: SwiftUIDetectionSignals): boolean[] { + return [hasSwiftUIManifest, hasAppStruct, hasCapacitorView, hasDelegateAdaptorShape, hasSceneDelegate]; } function describeSignals({ @@ -367,6 +618,7 @@ function describeSignals({ hasAppStruct, hasCapacitorView, hasDelegateAdaptorShape, + hasSceneDelegate, }: SwiftUIDetectionSignals): string { const present: string[] = []; const missing: string[] = []; @@ -374,15 +626,21 @@ function describeSignals({ (hasAppStruct ? present : missing).push('App.swift'); (hasCapacitorView ? present : missing).push('CapacitorView.swift'); (hasDelegateAdaptorShape ? present : missing).push('AppDelegate without @main'); + (hasSceneDelegate ? present : missing).push('SceneDelegate.swift'); return `present: [${present.join(', ')}]; missing: [${missing.join(', ')}]`; } // Exported for tests. export const __testables = { + CONFIGURATION_FOR_CONNECTING_METHOD, classify, describeSignals, hasCustomDelegateBody, hasCustomWindowSetup, + isStockAppDelegate, + isStockSceneDelegate, + removeLeftoverStoryboard, rewriteAppDelegateForAdaptor, scanAndWarn, + writeSceneDelegate, }; diff --git a/cli/src/util/xcode.ts b/cli/src/util/xcode.ts index 64ab836a3..366df403b 100644 --- a/cli/src/util/xcode.ts +++ b/cli/src/util/xcode.ts @@ -1,6 +1,9 @@ import { writeFileSync } from 'fs-extra'; +import { basename } from 'path'; import { project as loadXcodeProject } from 'xcode'; -import type { XcodeProject } from 'xcode'; +import type { PbxprojSection, XcodeProject } from 'xcode'; + +const COMMENT_SUFFIX = '_comment'; /** * Register a Swift source file with the first native target of an Xcode project. @@ -39,10 +42,146 @@ export function addSwiftFileToAppTarget( return { added: true }; } +/** + * Unregister a Swift source file from an Xcode project. + * + * Clears the file's PBXBuildFile, its entry in every Sources build phase, its listing in + * any PBXGroup, and its PBXFileReference. Matching is by resolved file reference UUID, so + * nothing else that happens to share the name is touched. Idempotent: a second call is a + * no-op and leaves the file untouched. + * + * @param pbxprojPath Absolute path to project.pbxproj + * @param fileName Basename of the Swift file to unregister (e.g. 'SceneDelegate.swift') + */ +export function removeSwiftFileFromAppTarget(pbxprojPath: string, fileName: string): { removed: boolean } { + const project = loadXcodeProject(pbxprojPath); + project.parseSync(); + + const objects = project.hash.project.objects; + const fileRefUuids = findSwiftFileUuidsByFileName(objects.PBXFileReference, fileName); + if (fileRefUuids.length === 0) { + return { removed: false }; + } + + const buildFileUuids = removeFromBuildFileSection(objects.PBXBuildFile, fileRefUuids); + removeFromBuildPhaseFiles(objects.PBXSourcesBuildPhase, buildFileUuids); + removeFromGroupChildren(objects.PBXGroup, fileRefUuids); + for (const uuid of fileRefUuids) { + deleteWithComment(objects.PBXFileReference, uuid); + } + + writeFileSync(pbxprojPath, project.writeSync(), 'utf-8'); + return { removed: true }; +} + +/** + * Unregister a storyboard from an Xcode project. + * + * A localized storyboard is a PBXVariantGroup whose children are the per-language + * PBXFileReferences, and it is the variant group — not the file reference — that the + * PBXBuildFile and the owning PBXGroup point at. Both levels are removed here, along with + * the Resources build phase entry. Falls back to a bare PBXFileReference for a project + * whose storyboard was moved out of Base.lproj. Idempotent: a second call is a no-op. + * + * @param pbxprojPath Absolute path to project.pbxproj + * @param storyboardName Basename of the storyboard to unregister (e.g. 'Main.storyboard') + */ +export function removeStoryboardFromAppTarget(pbxprojPath: string, storyboardName: string): { removed: boolean } { + const project = loadXcodeProject(pbxprojPath); + project.parseSync(); + + const objects = project.hash.project.objects; + const variantGroupUuids = findVariantGroupUuidsByName(objects.PBXVariantGroup, storyboardName); + const fileRefUuids = findStoryboardFileRefUuids(objects.PBXFileReference, storyboardName); + const uuids = [...variantGroupUuids, ...fileRefUuids]; + if (uuids.length === 0) { + return { removed: false }; + } + + const buildFileUuids = removeFromBuildFileSection(objects.PBXBuildFile, uuids); + removeFromBuildPhaseFiles(objects.PBXResourcesBuildPhase, buildFileUuids); + removeFromGroupChildren(objects.PBXGroup, uuids); + for (const uuid of variantGroupUuids) { + deleteWithComment(objects.PBXVariantGroup!, uuid); + } + for (const uuid of fileRefUuids) { + deleteWithComment(objects.PBXFileReference, uuid); + } + + writeFileSync(pbxprojPath, project.writeSync(), 'utf-8'); + return { removed: true }; +} + +function findSwiftFileUuidsByFileName(section: PbxprojSection, fileName: string): string[] { + return sectionEntries(section) + .filter( + ([, fileReference]) => + unquote(fileReference['lastKnownFileType']) === 'sourcecode.swift' && + basename(unquote(fileReference['path'])) === fileName, + ) + .map(([uuid]) => uuid); +} + +function findVariantGroupUuidsByName(section: PbxprojSection | undefined, storyboardName: string): string[] { + if (!section) return []; + return sectionEntries(section) + .filter(([, group]) => unquote(group['name']) === storyboardName) + .map(([uuid]) => uuid); +} + +function findStoryboardFileRefUuids(section: PbxprojSection, storyboardName: string): string[] { + return sectionEntries(section) + .filter(([, fileReference]) => basename(unquote(fileReference['path'])) === storyboardName) + .map(([uuid]) => uuid); +} + +/** Deletes every PBXBuildFile pointing at one of `fileRefUuids` and returns their UUIDs. */ +function removeFromBuildFileSection(section: PbxprojSection, fileRefUuids: string[]): string[] { + const removed: string[] = []; + for (const [uuid, buildFile] of sectionEntries(section)) { + if (!fileRefUuids.includes(buildFile['fileRef'] as string)) continue; + removed.push(uuid); + deleteWithComment(section, uuid); + } + return removed; +} + +function removeFromBuildPhaseFiles(section: PbxprojSection | undefined, buildFileUuids: string[]): void { + if (!section) return; + for (const [, buildPhase] of sectionEntries(section)) { + const files = buildPhase['files'] as { value: string }[] | undefined; + if (!Array.isArray(files)) continue; + buildPhase['files'] = files.filter((file) => !buildFileUuids.includes(file.value)); + } +} + +function removeFromGroupChildren(section: PbxprojSection, uuids: string[]): void { + for (const [, group] of sectionEntries(section)) { + const children = group['children'] as { value: string }[] | undefined; + if (!Array.isArray(children)) continue; + group['children'] = children.filter((child) => !uuids.includes(child.value)); + } +} + +function sectionEntries(section: PbxprojSection): [string, Record][] { + return Object.entries(section).filter( + (entry): entry is [string, Record] => + !entry[0].endsWith(COMMENT_SUFFIX) && typeof entry[1] !== 'string', + ); +} + +function deleteWithComment(section: PbxprojSection, uuid: string): void { + delete section[uuid]; + delete section[`${uuid}${COMMENT_SUFFIX}`]; +} + +function unquote(value: unknown): string { + return typeof value === 'string' ? value.replace(/^"(.*)"$/, '$1') : ''; +} + // Exported for tests. export function findGroupUuidByComment(project: XcodeProject, comment: string): string | null { const groups = project.hash.project.objects.PBXGroup; - const COMMENT_SUFFIX = '_comment'; for (const key of Object.keys(groups)) { if (!key.endsWith(COMMENT_SUFFIX)) continue; if (groups[key] === comment) { diff --git a/cli/test/fixtures/pre-swiftui-project.pbxproj b/cli/test/fixtures/pre-swiftui-project.pbxproj new file mode 100644 index 000000000..87225f719 --- /dev/null +++ b/cli/test/fixtures/pre-swiftui-project.pbxproj @@ -0,0 +1,380 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 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 */; }; + 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 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 = ""; }; + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */, + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 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; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + 9582B6832FE993A70072D4E8 /* SceneDelegate.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 = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.App; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.getcapacitor.App; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/cli/test/migrate-swiftui-app-delegate.spec.ts b/cli/test/migrate-swiftui-app-delegate.spec.ts index d266981f8..3062d0d91 100644 --- a/cli/test/migrate-swiftui-app-delegate.spec.ts +++ b/cli/test/migrate-swiftui-app-delegate.spec.ts @@ -1,6 +1,6 @@ import { __testables } from '../src/tasks/migrate-swiftui'; -const { rewriteAppDelegateForAdaptor, hasCustomWindowSetup } = __testables; +const { rewriteAppDelegateForAdaptor, hasCustomWindowSetup, CONFIGURATION_FOR_CONNECTING_METHOD } = __testables; // The AppDelegate shipped by the 8.4 templates: @main, UIResponder, own window. const APP_DELEGATE_8_4 = `import UIKit @@ -46,6 +46,46 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } `; +// The AppDelegate shipped by the 8.5 CocoaPods template, which — unlike the SPM one — +// kept the pre-UIScene URL and activity proxy handlers alongside configurationForConnecting. +const APP_DELEGATE_PODS_8_5 = `import UIKit +import Capacitor + +@UIApplicationMain +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 { + // 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 + } + +} +`; + function rewrite(source: string): string { const result = rewriteAppDelegateForAdaptor(source); if (result.status !== 'rewritten') { @@ -78,12 +118,11 @@ describe('rewriteAppDelegateForAdaptor', () => { expect(patched).toContain('class AppDelegate: NSObject, UIApplicationDelegate {'); }); - it('removes configurationForConnecting from the 8.5 AppDelegate', () => { + it('keeps the 8.5 configurationForConnecting, which already names SceneDelegate', () => { 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('configurationForConnecting'); + expect(patched).toContain('config.delegateClass = SceneDelegate.self'); expect(patched).toContain('didFinishLaunchingWithOptions'); expect(patched).not.toMatch(/\n\n\n/); }); @@ -99,20 +138,67 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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 + } } `); }); + it('adds configurationForConnecting to an 8.4 AppDelegate, which has none', () => { + const patched = rewrite(APP_DELEGATE_8_4); + + expect(patched).toContain('configurationForConnecting connectingSceneSession: UISceneSession'); + expect(patched).toContain('configuration.delegateClass = SceneDelegate.self'); + expect(patched).toMatch(/}\n$/); + expect(patched).not.toMatch(/\n\n\n/); + }); + + it('adds the method byte-for-byte as the templates ship it', () => { + expect(rewrite(APP_DELEGATE_8_4)).toContain(CONFIGURATION_FOR_CONNECTING_METHOD); + }); + 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', () => { + it('removes a vanilla application(_:open:), which scenes no longer call', () => { const patched = rewrite(APP_DELEGATE_8_4); - expect(patched).toContain('ApplicationDelegateProxy.shared.application(app, open: url, options: options)'); + expect(patched).not.toContain('open url:'); + expect(patched).not.toContain('ApplicationDelegateProxy.shared'); + expect(patched).toContain('didFinishLaunchingWithOptions'); + }); + + it('removes a vanilla application(_:continue:) alongside it', () => { + const patched = rewrite(APP_DELEGATE_PODS_8_5); + + expect(patched).not.toContain('open url:'); + expect(patched).not.toContain('continue userActivity:'); + expect(patched).not.toContain('ApplicationDelegateProxy.shared'); + expect(patched).toContain('configurationForConnecting'); + expect(patched).toContain('didFinishLaunchingWithOptions'); + expect(patched).not.toMatch(/\n\n\n/); + }); + + it('keeps the developer-added members when stripping the dead handlers', () => { + const customised = APP_DELEGATE_PODS_8_5.replace( + ' var window: UIWindow?', + ' var window: UIWindow?\n\n var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid', + ); + + const patched = rewrite(customised); + + expect(patched).toContain('var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid'); + expect(patched).not.toContain('ApplicationDelegateProxy.shared'); }); it('refuses when no AppDelegate class is present', () => { @@ -168,7 +254,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { expect(result.status === 'skipped' && result.reason).toMatch(/application\(_:continue:\)/); }); - it('refuses when configurationForConnecting braces are unbalanced', () => { + it('refuses when the AppDelegate class braces are unbalanced', () => { const source = `class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, @@ -179,7 +265,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { const result = rewriteAppDelegateForAdaptor(source); expect(result.status).toBe('skipped'); - expect(result.status === 'skipped' && result.reason).toMatch(/configurationForConnecting/); + expect(result.status === 'skipped' && result.reason).toMatch(/end of the AppDelegate class/); }); }); diff --git a/cli/test/migrate-swiftui-detect.spec.ts b/cli/test/migrate-swiftui-detect.spec.ts index 600caaac4..b6f72f80f 100644 --- a/cli/test/migrate-swiftui-detect.spec.ts +++ b/cli/test/migrate-swiftui-detect.spec.ts @@ -7,6 +7,7 @@ const NOTHING_MIGRATED = { hasAppStruct: false, hasCapacitorView: false, hasDelegateAdaptorShape: false, + hasSceneDelegate: false, }; const FULLY_MIGRATED = { @@ -14,6 +15,7 @@ const FULLY_MIGRATED = { hasAppStruct: true, hasCapacitorView: true, hasDelegateAdaptorShape: true, + hasSceneDelegate: true, }; describe('migrate-swiftui classify', () => { @@ -25,12 +27,12 @@ describe('migrate-swiftui classify', () => { 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(NOTHING_MIGRATED))('stays eligible when only %s is present, so the run can resume', (key) => { + expect(classify({ ...NOTHING_MIGRATED, [key]: true })).toBe('eligible'); }); - it.each(Object.keys(FULLY_MIGRATED))('returns partial when only %s is missing', (key) => { - expect(classify({ ...FULLY_MIGRATED, [key]: false })).toBe('partial'); + it.each(Object.keys(FULLY_MIGRATED))('stays eligible when only %s is missing, so the run can resume', (key) => { + expect(classify({ ...FULLY_MIGRATED, [key]: false })).toBe('eligible'); }); }); @@ -41,6 +43,13 @@ describe('migrate-swiftui describeSignals', () => { expect(description).toContain('present: []'); expect(description).toContain('App.swift'); expect(description).toContain('CapacitorView.swift'); + expect(description).toContain('SceneDelegate.swift'); + }); + + it('reports a project migrated before the scene delegate landed as missing only that', () => { + const description = describeSignals({ ...FULLY_MIGRATED, hasSceneDelegate: false }); + + expect(description).toMatch(/missing: \[SceneDelegate\.swift\]/); }); it('splits present and missing markers', () => { diff --git a/cli/test/migrate-swiftui-scene-delegate.spec.ts b/cli/test/migrate-swiftui-scene-delegate.spec.ts new file mode 100644 index 000000000..cfbb08119 --- /dev/null +++ b/cli/test/migrate-swiftui-scene-delegate.spec.ts @@ -0,0 +1,202 @@ +import { existsSync, mkdirpSync, readFileSync, writeFileSync } from 'fs-extra'; +import { join, resolve } from 'path'; + +import type { Config } from '../src/definitions'; +import { __testables } from '../src/tasks/migrate-swiftui'; + +import { mktmp } from './util'; + +const { isStockSceneDelegate, removeLeftoverStoryboard, writeSceneDelegate } = __testables; + +const PRE_SWIFTUI_PBXPROJ = resolve(__dirname, 'fixtures/pre-swiftui-project.pbxproj'); +const TEMPLATE = readFileSync(resolve(__dirname, '../../ios-spm-template/App/App/SceneDelegate.swift'), 'utf-8'); + +const STOCK = `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) + } +} +`; + +describe('isStockSceneDelegate', () => { + it('accepts the 8.x template verbatim', () => { + expect(isStockSceneDelegate(STOCK)).toBe(true); + }); + + it('accepts a template annotated with comments', () => { + const commented = STOCK.replace('var window: UIWindow?', '// the app window\n var window: UIWindow?'); + + expect(isStockSceneDelegate(commented)).toBe(true); + }); + + it('rejects an extra stored property', () => { + const customised = STOCK.replace('var window: UIWindow?', 'var window: UIWindow?\n var deepLink: URL?'); + + expect(isStockSceneDelegate(customised)).toBe(false); + }); + + it('rejects an extra method', () => { + const customised = STOCK.replace( + ' }\n}\n', + ' }\n\n func sceneDidBecomeActive(_ scene: UIScene) {\n Analytics.track("foreground")\n }\n}\n', + ); + + expect(isStockSceneDelegate(customised)).toBe(false); + }); + + it('rejects an extra statement inside willConnectTo', () => { + const customised = STOCK.replace( + 'window?.makeKeyAndVisible()', + 'window?.makeKeyAndVisible()\n window?.tintColor = .systemPink', + ); + + expect(isStockSceneDelegate(customised)).toBe(false); + }); + + it('rejects a custom root view controller', () => { + const customised = STOCK.replace('CAPBridgeViewController()', 'MyBridgeViewController()'); + + expect(isStockSceneDelegate(customised)).toBe(false); + }); + + it('rejects a non-proxy openURLContexts body', () => { + const customised = STOCK.replace( + 'SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)', + 'Router.handle(URLContexts)', + ); + + expect(isStockSceneDelegate(customised)).toBe(false); + }); + + it('rejects a non-proxy continue body', () => { + const customised = STOCK.replace( + 'SceneDelegateProxy.shared.scene(scene, continue: userActivity)', + 'Router.handle(userActivity)', + ); + + expect(isStockSceneDelegate(customised)).toBe(false); + }); + + it('rejects a file with no SceneDelegate class', () => { + expect(isStockSceneDelegate('import UIKit\n')).toBe(false); + }); +}); + +describe('writeSceneDelegate', () => { + let tmpDir: any; + let path: string; + + beforeEach(async () => { + tmpDir = await mktmp(); + path = join(tmpDir.path, 'SceneDelegate.swift'); + }); + + afterEach(() => { + tmpDir.cleanupCallback(); + }); + + it('writes the template when no SceneDelegate exists', () => { + expect(writeSceneDelegate(path, TEMPLATE)).toBe('written'); + expect(readFileSync(path, 'utf-8')).toBe(TEMPLATE); + }); + + it('replaces a stock 8.x SceneDelegate, whose window setup the App struct now owns', () => { + writeFileSync(path, STOCK); + + expect(writeSceneDelegate(path, TEMPLATE)).toBe('replaced'); + expect(readFileSync(path, 'utf-8')).toBe(TEMPLATE); + }); + + it('keeps a customised SceneDelegate untouched', () => { + const customised = STOCK.replace('var window: UIWindow?', 'var deepLink: URL?'); + writeFileSync(path, customised); + + expect(writeSceneDelegate(path, TEMPLATE)).toBe('kept'); + expect(readFileSync(path, 'utf-8')).toBe(customised); + }); + + it('is idempotent — the written template is itself stock', () => { + writeSceneDelegate(path, TEMPLATE); + + expect(writeSceneDelegate(path, TEMPLATE)).toBe('replaced'); + expect(readFileSync(path, 'utf-8')).toBe(TEMPLATE); + }); +}); + +describe('removeLeftoverStoryboard', () => { + let tmpDir: any; + let config: Config; + let targetDir: string; + let pbxprojPath: string; + + beforeEach(async () => { + tmpDir = await mktmp(); + targetDir = join(tmpDir.path, 'App', 'App'); + const xcodeProjDir = join(tmpDir.path, 'App', 'App.xcodeproj'); + pbxprojPath = join(xcodeProjDir, 'project.pbxproj'); + + mkdirpSync(join(targetDir, 'Base.lproj')); + mkdirpSync(xcodeProjDir); + writeFileSync(pbxprojPath, readFileSync(PRE_SWIFTUI_PBXPROJ, 'utf-8')); + writeFileSync(join(targetDir, 'SceneDelegate.swift'), STOCK); + writeFileSync(join(targetDir, 'Base.lproj', 'Main.storyboard'), '\n'); + writeFileSync(join(targetDir, 'Base.lproj', 'LaunchScreen.storyboard'), '\n'); + + config = { + ios: { nativeTargetDirAbs: targetDir, nativeXcodeProjDirAbs: xcodeProjDir }, + } as unknown as Config; + }); + + afterEach(() => { + tmpDir.cleanupCallback(); + }); + + it('removes Main.storyboard from disk and from the pbxproj', () => { + removeLeftoverStoryboard(config); + + expect(existsSync(join(targetDir, 'Base.lproj', 'Main.storyboard'))).toBe(false); + expect(readFileSync(pbxprojPath, 'utf-8')).not.toContain('Main.storyboard'); + }); + + it('leaves SceneDelegate.swift on disk and registered, because it is the live scene delegate', () => { + removeLeftoverStoryboard(config); + + expect(existsSync(join(targetDir, 'SceneDelegate.swift'))).toBe(true); + expect(readFileSync(pbxprojPath, 'utf-8')).toContain('SceneDelegate.swift'); + }); + + it('keeps LaunchScreen.storyboard on disk and registered', () => { + removeLeftoverStoryboard(config); + + expect(existsSync(join(targetDir, 'Base.lproj', 'LaunchScreen.storyboard'))).toBe(true); + expect(readFileSync(pbxprojPath, 'utf-8')).toContain('LaunchScreen.storyboard'); + }); + + it('makes no further changes on a second run', () => { + removeLeftoverStoryboard(config); + const after = readFileSync(pbxprojPath, 'utf-8'); + + removeLeftoverStoryboard(config); + + expect(readFileSync(pbxprojPath, 'utf-8')).toBe(after); + }); +}); diff --git a/cli/test/migrate-swiftui-stock-app-delegate.spec.ts b/cli/test/migrate-swiftui-stock-app-delegate.spec.ts new file mode 100644 index 000000000..46c693f7f --- /dev/null +++ b/cli/test/migrate-swiftui-stock-app-delegate.spec.ts @@ -0,0 +1,166 @@ +import { readFileSync } from 'fs-extra'; +import { resolve } from 'path'; + +import { __testables } from '../src/tasks/migrate-swiftui'; + +const { isStockAppDelegate } = __testables; + +const REPO_ROOT = resolve(__dirname, '..', '..'); + +// The AppDelegate shipped by the 8.x templates, verbatim: UIScene-era, with +// configurationForConnecting pointing at SceneDelegate. +const STOCK_8_X = `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 applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. + } + + 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 + } +} +`; + +// The pre-UIScene shape a 6.x or 7.x app carries into a 9.0 migration: no +// configurationForConnecting, but open-URL and universal-link proxy handlers. +const STOCK_7_X = `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 applicationWillResignActive(_ application: UIApplication) { + } + + func applicationWillTerminate(_ application: UIApplication) { + } + + 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 { + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + +} +`; + +// The 6.x shape, before @UIApplicationMain was replaced with @main. +const STOCK_6_X = STOCK_7_X.replace('@main', '@UIApplicationMain'); + +describe('isStockAppDelegate', () => { + it.each([['ios-spm-template/App/App/AppDelegate.swift'], ['ios-pods-template/App/App/AppDelegate.swift']])( + 'accepts the shipped 9.0 template at %s, so a second run is a no-op', + (templatePath) => { + expect(isStockAppDelegate(readFileSync(resolve(REPO_ROOT, templatePath), 'utf-8'))).toBe(true); + }, + ); + + it.each([ + ['8.x', STOCK_8_X], + ['7.x', STOCK_7_X], + ['6.x', STOCK_6_X], + ])('accepts the stock %s AppDelegate', (_label, source) => { + expect(isStockAppDelegate(source)).toBe(true); + }); + + it('rejects a body added to a lifecycle stub', () => { + const customised = STOCK_8_X.replace( + ' // Restart any tasks that were paused.', + ' Analytics.track("foreground")', + ); + + expect(isStockAppDelegate(customised)).toBe(false); + }); + + it('rejects work added to didFinishLaunchingWithOptions', () => { + const customised = STOCK_8_X.replace(' return true', ' FirebaseApp.configure()\n return true'); + + expect(isStockAppDelegate(customised)).toBe(false); + }); + + it('rejects a non-proxy open-URL handler', () => { + const customised = STOCK_7_X.replace( + 'return ApplicationDelegateProxy.shared.application(app, open: url, options: options)', + 'return Router.handle(url)', + ); + + expect(isStockAppDelegate(customised)).toBe(false); + }); + + it('rejects an added method', () => { + const customised = STOCK_8_X.replace( + ' }\n}\n', + ' }\n\n func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {\n NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)\n }\n}\n', + ); + + expect(isStockAppDelegate(customised)).toBe(false); + }); + + it('rejects an added stored property', () => { + const customised = STOCK_8_X.replace('var window: UIWindow?', 'var window: UIWindow?\n var pendingURL: URL?'); + + expect(isStockAppDelegate(customised)).toBe(false); + }); + + it('rejects an added import', () => { + const customised = STOCK_8_X.replace('import Capacitor', 'import Capacitor\nimport FirebaseCore'); + + expect(isStockAppDelegate(customised)).toBe(false); + }); + + it('rejects a delegate that conforms to an extra protocol', () => { + const customised = STOCK_8_X.replace( + 'class AppDelegate: UIResponder, UIApplicationDelegate {', + 'class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {', + ); + + expect(isStockAppDelegate(customised)).toBe(false); + }); + + it('rejects a file with no AppDelegate class', () => { + expect(isStockAppDelegate('import UIKit\n')).toBe(false); + }); +}); diff --git a/cli/test/migrate-swiftui-strip-guard.spec.ts b/cli/test/migrate-swiftui-strip-guard.spec.ts new file mode 100644 index 000000000..133a1b2a6 --- /dev/null +++ b/cli/test/migrate-swiftui-strip-guard.spec.ts @@ -0,0 +1,203 @@ +import { existsSync, mkdirpSync, readFileSync, writeFileSync } from 'fs-extra'; +import { join, resolve } from 'path'; + +import type { Config } from '../src/definitions'; +import { migrateToSwiftUI } from '../src/tasks/migrate-swiftui'; + +import { mktmp } from './util'; + +const PRE_SWIFTUI_PBXPROJ = resolve(__dirname, 'fixtures/pre-swiftui-project.pbxproj'); +const REPO_ROOT = resolve(__dirname, '..', '..'); + +let registrationThrows = false; + +jest.mock('../src/util/xcode', () => { + const actual = jest.requireActual('../src/util/xcode'); + return { + ...actual, + addSwiftFileToAppTarget: (...args: unknown[]) => { + if (registrationThrows) { + throw new Error('Could not find PBXGroup with comment "App"'); + } + return (actual.addSwiftFileToAppTarget as (...a: unknown[]) => unknown)(...args); + }, + }; +}); + +// Stand in for the packed template tarballs so the test does not depend on `npm run assets`. +jest.mock('../src/util/template', () => ({ + extractTemplate: async (_archivePath: string, dir: string) => { + const appDir = join(dir, 'App', 'App'); + mkdirpSync(appDir); + for (const name of ['App.swift', 'AppDelegate.swift', 'CapacitorView.swift', 'SceneDelegate.swift']) { + writeFileSync(join(appDir, name), templateSource(name)); + } + }, +})); + +function templateSource(name: string): string { + return readFileSync(resolve(REPO_ROOT, 'ios-spm-template/App/App', name), 'utf-8'); +} + +const INFO_PLIST = ` + + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + +`; + +const APP_DELEGATE_8_5 = `import UIKit +import Capacitor + +@UIApplicationMain +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 + } +} +`; + +const SCENE_DELEGATE_8_5 = `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) + } +} +`; + +describe('migrateToSwiftUI strip guard', () => { + let tmpDir: any; + let targetDir: string; + let pbxprojPath: string; + let config: Config; + + beforeEach(async () => { + registrationThrows = false; + tmpDir = await mktmp(); + + const platformDir = join(tmpDir.path, 'ios'); + const xcodeProjDir = join(platformDir, 'App', 'App.xcodeproj'); + targetDir = join(platformDir, 'App', 'App'); + pbxprojPath = join(xcodeProjDir, 'project.pbxproj'); + + mkdirpSync(join(targetDir, 'Base.lproj')); + mkdirpSync(xcodeProjDir); + writeFileSync(pbxprojPath, readFileSync(PRE_SWIFTUI_PBXPROJ, 'utf-8')); + writeFileSync(join(targetDir, 'Info.plist'), INFO_PLIST); + writeFileSync(join(targetDir, 'AppDelegate.swift'), APP_DELEGATE_8_5); + writeFileSync(join(targetDir, 'SceneDelegate.swift'), SCENE_DELEGATE_8_5); + writeFileSync(join(targetDir, 'Base.lproj', 'Main.storyboard'), '\n'); + writeFileSync(join(targetDir, 'Base.lproj', 'LaunchScreen.storyboard'), '\n'); + + config = { + ios: { + platformDirAbs: platformDir, + nativeTargetDirAbs: targetDir, + nativeXcodeProjDirAbs: xcodeProjDir, + packageManager: 'SPM', + }, + cli: { assetsDirAbs: join(tmpDir.path, 'assets') }, + } as unknown as Config; + mkdirpSync(join(tmpDir.path, 'assets')); + }); + + afterEach(() => { + tmpDir.cleanupCallback(); + }); + + it('strips the main storyboard when registration succeeds', async () => { + await migrateToSwiftUI(config); + + expect(existsSync(join(targetDir, 'Base.lproj', 'Main.storyboard'))).toBe(false); + expect(readFileSync(pbxprojPath, 'utf-8')).not.toContain('Main.storyboard'); + }); + + it('replaces the stock 8.5 SceneDelegate with the 9.0 template and keeps it registered', async () => { + await migrateToSwiftUI(config); + + expect(readFileSync(join(targetDir, 'SceneDelegate.swift'), 'utf-8')).toBe(templateSource('SceneDelegate.swift')); + expect(readFileSync(pbxprojPath, 'utf-8')).toContain('SceneDelegate.swift'); + }); + + it('gives the stock 8.5 AppDelegate the template version, which still names SceneDelegate', async () => { + await migrateToSwiftUI(config); + + expect(readFileSync(join(targetDir, 'AppDelegate.swift'), 'utf-8')).toBe(templateSource('AppDelegate.swift')); + expect(readFileSync(join(targetDir, 'AppDelegate.swift'), 'utf-8')).toContain( + 'configuration.delegateClass = SceneDelegate.self', + ); + }); + + it('keeps every UIKit file when the App target could not be updated', async () => { + registrationThrows = true; + + await migrateToSwiftUI(config); + + expect(existsSync(join(targetDir, 'SceneDelegate.swift'))).toBe(true); + expect(existsSync(join(targetDir, 'Base.lproj', 'Main.storyboard'))).toBe(true); + + const pbxproj = readFileSync(pbxprojPath, 'utf-8'); + expect(pbxproj).toContain('SceneDelegate.swift'); + expect(pbxproj).toContain('Main.storyboard'); + }); + + it('keeps every UIKit file when the AppDelegate conversion is refused', async () => { + writeFileSync( + join(targetDir, 'AppDelegate.swift'), + APP_DELEGATE_8_5.replace( + ' return true', + ' window = UIWindow(frame: UIScreen.main.bounds)\n return true', + ), + ); + + await migrateToSwiftUI(config); + + expect(existsSync(join(targetDir, 'SceneDelegate.swift'))).toBe(true); + expect(existsSync(join(targetDir, 'Base.lproj', 'Main.storyboard'))).toBe(true); + expect(readFileSync(pbxprojPath, 'utf-8')).toContain('SceneDelegate.swift'); + }); +}); diff --git a/cli/test/xcode.spec.ts b/cli/test/xcode.spec.ts index 9e0a7788b..af96446bb 100644 --- a/cli/test/xcode.spec.ts +++ b/cli/test/xcode.spec.ts @@ -2,12 +2,22 @@ import { readFileSync, writeFileSync } from 'fs-extra'; import { join, resolve } from 'path'; import { project as loadXcodeProject } from 'xcode'; -import { addSwiftFileToAppTarget, findGroupUuidByComment } from '../src/util/xcode'; +import { + addSwiftFileToAppTarget, + findGroupUuidByComment, + removeStoryboardFromAppTarget, + removeSwiftFileFromAppTarget, +} from '../src/util/xcode'; import { mktmp } from './util'; const REPO_ROOT = resolve(__dirname, '..', '..'); const SHIPPED_PBXPROJ = resolve(REPO_ROOT, 'ios-spm-template/App/App.xcodeproj/project.pbxproj'); +const PRE_SWIFTUI_PBXPROJ = resolve(__dirname, 'fixtures/pre-swiftui-project.pbxproj'); + +const SCENE_DELEGATE_UUIDS = ['9582B6822FE993A50072D4E8', '9582B6832FE993A70072D4E8']; +const MAIN_STORYBOARD_UUIDS = ['504EC30B1FED79650016851F', '504EC30C1FED79650016851F', '504EC30D1FED79650016851F']; +const LAUNCH_SCREEN_UUIDS = ['504EC3101FED79650016851F', '504EC3111FED79650016851F', '504EC3121FED79650016851F']; function stripCapacitorView(source: string): string { return source @@ -112,3 +122,137 @@ describe('addSwiftFileToAppTarget', () => { ); }); }); + +describe('removing UIKit entry-point files from a pre-SwiftUI project', () => { + let tmpDir: any; + let pbxprojPath: string; + + beforeEach(async () => { + tmpDir = await mktmp(); + pbxprojPath = join(tmpDir.path, 'project.pbxproj'); + writeFileSync(pbxprojPath, readFileSync(PRE_SWIFTUI_PBXPROJ, 'utf-8')); + }); + + afterEach(() => { + tmpDir.cleanupCallback(); + }); + + describe('removeSwiftFileFromAppTarget', () => { + it('clears SceneDelegate.swift from all four pbxproj sections', () => { + expect(removeSwiftFileFromAppTarget(pbxprojPath, 'SceneDelegate.swift').removed).toBe(true); + + const objects = reparse(pbxprojPath); + + expect(entries(objects.PBXFileReference).some(([, ref]) => unquote(ref.path) === 'SceneDelegate.swift')).toBe( + false, + ); + expect(entries(objects.PBXBuildFile).some(([, f]) => f.fileRef === SCENE_DELEGATE_UUIDS[0])).toBe(false); + expect(appGroupChildComments(pbxprojPath)).not.toContain('SceneDelegate.swift'); + expect(buildPhaseComments(objects.PBXSourcesBuildPhase).join()).not.toContain('SceneDelegate.swift'); + }); + + it('leaves AppDelegate.swift registered', () => { + removeSwiftFileFromAppTarget(pbxprojPath, 'SceneDelegate.swift'); + + const objects = reparse(pbxprojPath); + + expect(entries(objects.PBXFileReference).some(([, ref]) => unquote(ref.path) === 'AppDelegate.swift')).toBe(true); + expect(buildPhaseComments(objects.PBXSourcesBuildPhase).join()).toContain('AppDelegate.swift'); + }); + + it('leaves no dangling references to the removed UUIDs', () => { + removeSwiftFileFromAppTarget(pbxprojPath, 'SceneDelegate.swift'); + + const written = readFileSync(pbxprojPath, 'utf-8'); + for (const uuid of SCENE_DELEGATE_UUIDS) { + expect(written).not.toContain(uuid); + } + }); + + it('is a no-op once the file is gone', () => { + expect(removeSwiftFileFromAppTarget(pbxprojPath, 'SceneDelegate.swift').removed).toBe(true); + + const after = readFileSync(pbxprojPath, 'utf-8'); + expect(removeSwiftFileFromAppTarget(pbxprojPath, 'SceneDelegate.swift').removed).toBe(false); + expect(readFileSync(pbxprojPath, 'utf-8')).toBe(after); + }); + }); + + describe('removeStoryboardFromAppTarget', () => { + it('clears the Main.storyboard variant group, its Base file reference and its build entries', () => { + expect(removeStoryboardFromAppTarget(pbxprojPath, 'Main.storyboard').removed).toBe(true); + + const objects = reparse(pbxprojPath); + + expect(entries(objects.PBXVariantGroup!).some(([, g]) => unquote(g.name) === 'Main.storyboard')).toBe(false); + expect( + entries(objects.PBXFileReference).some(([, ref]) => unquote(ref.path) === 'Base.lproj/Main.storyboard'), + ).toBe(false); + expect(entries(objects.PBXBuildFile).some(([, f]) => f.fileRef === MAIN_STORYBOARD_UUIDS[0])).toBe(false); + expect(appGroupChildComments(pbxprojPath)).not.toContain('Main.storyboard'); + expect(buildPhaseComments(objects.PBXResourcesBuildPhase).join()).not.toContain('Main.storyboard'); + }); + + it('leaves LaunchScreen.storyboard fully registered', () => { + removeStoryboardFromAppTarget(pbxprojPath, 'Main.storyboard'); + + const objects = reparse(pbxprojPath); + + expect(entries(objects.PBXVariantGroup!).some(([, g]) => unquote(g.name) === 'LaunchScreen.storyboard')).toBe( + true, + ); + expect( + entries(objects.PBXFileReference).some(([, ref]) => unquote(ref.path) === 'Base.lproj/LaunchScreen.storyboard'), + ).toBe(true); + expect(appGroupChildComments(pbxprojPath)).toContain('LaunchScreen.storyboard'); + expect(buildPhaseComments(objects.PBXResourcesBuildPhase).join()).toContain('LaunchScreen.storyboard'); + + const written = readFileSync(pbxprojPath, 'utf-8'); + for (const uuid of LAUNCH_SCREEN_UUIDS) { + expect(written).toContain(uuid); + } + }); + + it('leaves no dangling references to the removed UUIDs', () => { + removeStoryboardFromAppTarget(pbxprojPath, 'Main.storyboard'); + + const written = readFileSync(pbxprojPath, 'utf-8'); + for (const uuid of MAIN_STORYBOARD_UUIDS) { + expect(written).not.toContain(uuid); + } + }); + + it('is a no-op once the storyboard is gone', () => { + expect(removeStoryboardFromAppTarget(pbxprojPath, 'Main.storyboard').removed).toBe(true); + + const after = readFileSync(pbxprojPath, 'utf-8'); + expect(removeStoryboardFromAppTarget(pbxprojPath, 'Main.storyboard').removed).toBe(false); + expect(readFileSync(pbxprojPath, 'utf-8')).toBe(after); + }); + }); +}); + +function reparse(pbxprojPath: string) { + const project = loadXcodeProject(pbxprojPath); + project.parseSync(); + return project.hash.project.objects; +} + +function entries(section: Record): [string, any][] { + return Object.entries(section).filter(([key, value]) => !key.endsWith('_comment') && typeof value !== 'string'); +} + +function appGroupChildComments(pbxprojPath: string): string[] { + const project = loadXcodeProject(pbxprojPath); + project.parseSync(); + const appGroup = project.getPBXGroupByKey(findGroupUuidByComment(project, 'App')!)!; + return appGroup.children.map((child: any) => child.comment); +} + +function buildPhaseComments(section: Record | undefined): string[] { + return entries(section ?? {}).flatMap(([, phase]) => phase.files.map((file: any) => file.comment)); +} + +function unquote(value: unknown): string { + return typeof value === 'string' ? value.replace(/^"(.*)"$/, '$1') : ''; +} diff --git a/ios-pods-template/App/App.xcodeproj/project.pbxproj b/ios-pods-template/App/App.xcodeproj/project.pbxproj index 88d1ad204..05ca27a71 100644 --- a/ios-pods-template/App/App.xcodeproj/project.pbxproj +++ b/ios-pods-template/App/App.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 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 */; }; + 9582B68E2FE9ACE70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B68D2FE9ACE50072D4E8 /* SceneDelegate.swift */; }; A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; /* End PBXBuildFile section */ @@ -29,6 +30,7 @@ 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 = ""; }; + 9582B68D2FE9ACE50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.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 = ""; }; @@ -76,6 +78,7 @@ isa = PBXGroup; children = ( 952C1C63302642D3000D0FF6 /* AppDelegate.swift */, + 9582B68D2FE9ACE50072D4E8 /* SceneDelegate.swift */, 9582B68B2FE9ACBE0072D4E8 /* CapacitorView.swift */, 9582B6892FE9ABF10072D4E8 /* App.swift */, 50379B222058CBB4000EE86E /* capacitor.config.json */, @@ -214,6 +217,7 @@ 952C1C64302642D6000D0FF6 /* AppDelegate.swift in Sources */, 9582B68A2FE9ABF30072D4E8 /* App.swift in Sources */, 9582B68C2FE9ACC30072D4E8 /* CapacitorView.swift in Sources */, + 9582B68E2FE9ACE70072D4E8 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios-pods-template/App/App/AppDelegate.swift b/ios-pods-template/App/App/AppDelegate.swift index 77e33de0f..b232607e9 100644 --- a/ios-pods-template/App/App/AppDelegate.swift +++ b/ios-pods-template/App/App/AppDelegate.swift @@ -5,4 +5,14 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + configuration.delegateClass = SceneDelegate.self + return configuration + } } diff --git a/ios-pods-template/App/App/SceneDelegate.swift b/ios-pods-template/App/App/SceneDelegate.swift new file mode 100644 index 000000000..9e0dc2c57 --- /dev/null +++ b/ios-pods-template/App/App/SceneDelegate.swift @@ -0,0 +1,6 @@ +import UIKit + +// Deliberately empty. SwiftUI owns the window, and a scene delegate that implements +// scene(_:openURLContexts:) would take URL delivery away from App.swift's .onOpenURL. +// This exists so per-scene UIKit APIs that SwiftUI does not surface have somewhere to go. +class SceneDelegate: UIResponder, UIWindowSceneDelegate {} diff --git a/ios-spm-template/App/App.xcodeproj/project.pbxproj b/ios-spm-template/App/App.xcodeproj/project.pbxproj index 943645cc2..f5b16af88 100644 --- a/ios-spm-template/App/App.xcodeproj/project.pbxproj +++ b/ios-spm-template/App/App.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 9575E845302683A8004C1ED7 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9575E844302683A6004C1ED7 /* App.swift */; }; 9575E847302683C1004C1ED7 /* CapacitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9575E846302683BC004C1ED7 /* CapacitorView.swift */; }; + 9575E849302683D7004C1ED7 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9575E848302683D5004C1ED7 /* SceneDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -29,6 +30,7 @@ 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; 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 = ""; }; + 9575E848302683D5004C1ED7 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ @@ -68,6 +70,7 @@ 9575E844302683A6004C1ED7 /* App.swift */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + 9575E848302683D5004C1ED7 /* SceneDelegate.swift */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, 504EC3131FED79650016851F /* Info.plist */, @@ -160,6 +163,7 @@ 9575E845302683A8004C1ED7 /* App.swift in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, 9575E847302683C1004C1ED7 /* CapacitorView.swift in Sources */, + 9575E849302683D7004C1ED7 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios-spm-template/App/App/AppDelegate.swift b/ios-spm-template/App/App/AppDelegate.swift index 5fb4c09fb..3f540f65e 100644 --- a/ios-spm-template/App/App/AppDelegate.swift +++ b/ios-spm-template/App/App/AppDelegate.swift @@ -4,4 +4,14 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + configuration.delegateClass = SceneDelegate.self + return configuration + } } diff --git a/ios-spm-template/App/App/SceneDelegate.swift b/ios-spm-template/App/App/SceneDelegate.swift new file mode 100644 index 000000000..9e0dc2c57 --- /dev/null +++ b/ios-spm-template/App/App/SceneDelegate.swift @@ -0,0 +1,6 @@ +import UIKit + +// Deliberately empty. SwiftUI owns the window, and a scene delegate that implements +// scene(_:openURLContexts:) would take URL delivery away from App.swift's .onOpenURL. +// This exists so per-scene UIKit APIs that SwiftUI does not surface have somewhere to go. +class SceneDelegate: UIResponder, UIWindowSceneDelegate {}