diff --git a/cli/package.json b/cli/package.json index e0c99f6d0..627f846a3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -60,6 +60,7 @@ "semver": "^7.6.3", "tar": "^7.5.3", "tslib": "^2.8.1", + "xcode": "^3.0.1", "xml2js": "^0.6.2" }, "devDependencies": { @@ -70,6 +71,7 @@ "@types/prompts": "^2.4.9", "@types/semver": "^7.5.8", "@types/tmp": "^0.2.6", + "@types/xcode": "^3.0.0", "@types/xml2js": "0.4.5", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/cli/src/tasks/migrate-uiscene.ts b/cli/src/tasks/migrate-uiscene.ts new file mode 100644 index 000000000..9f6dfa278 --- /dev/null +++ b/cli/src/tasks/migrate-uiscene.ts @@ -0,0 +1,305 @@ +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 8524ad733..eec9673d5 100644 --- a/cli/src/tasks/migrate.ts +++ b/cli/src/tasks/migrate.ts @@ -13,6 +13,8 @@ import { deleteFolderRecursive } from '../util/fs'; import { runCommand } from '../util/subprocess'; import { extractTemplate } from '../util/template'; +import { migrateToUIScene } from './migrate-uiscene'; + // eslint-disable-next-line prefer-const let allDependencies: { [key: string]: any } = {}; const libs = ['@capacitor/core', '@capacitor/cli', '@capacitor/ios', '@capacitor/android']; @@ -175,6 +177,8 @@ export async function migrateCommand(config: Config, noprompt: boolean, packagem } else { logger.warn('Skipped updating deployment target'); } + + await migrateToUIScene(config); } if (!installFailed) { @@ -391,6 +395,12 @@ 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.', + ); + } } async function getAndroidVariablesAndClasspaths(config: Config) { diff --git a/cli/src/util/spm.ts b/cli/src/util/spm.ts index 2ef26bcda..65cd0f11f 100644 --- a/cli/src/util/spm.ts +++ b/cli/src/util/spm.ts @@ -239,6 +239,48 @@ export async function addInfoPlistDebugIfNeeded(config: Config): Promise { } } +export function hasSceneManifest(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; + return entries['UIApplicationSceneManifest'] !== undefined; +} + +export async function addSceneManifestIfNeeded(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; + } + + const entries = parse(readFileSync(infoPlist, 'utf-8')) as Mutable; + + if (entries['UIApplicationSceneManifest'] !== undefined) { + logger.warn('Found UIApplicationSceneManifest in ' + infoPlist + ', skipping.'); + return; + } + + entries['UIApplicationSceneManifest'] = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: 'Default Configuration', + UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate', + UISceneStoryboardFile: 'Main', + }, + ], + }, + }; + + writeFileSync(infoPlist, build(entries)); +} + export async function checkSwiftToolsVersion(config: Config, version: string | undefined): Promise { if (!version) { return null; diff --git a/cli/src/util/xcode.ts b/cli/src/util/xcode.ts new file mode 100644 index 000000000..64ab836a3 --- /dev/null +++ b/cli/src/util/xcode.ts @@ -0,0 +1,53 @@ +import { writeFileSync } from 'fs-extra'; +import { project as loadXcodeProject } from 'xcode'; +import type { XcodeProject } from 'xcode'; + +/** + * Register a Swift source file with the first native target of an Xcode project. + * + * Writes entries in PBXFileReference, PBXBuildFile, the named PBXGroup's children, + * and the target's Sources build phase. Idempotent: a second call is a no-op. + * + * @param pbxprojPath Absolute path to project.pbxproj + * @param groupName Comment/name of the PBXGroup the file belongs under (e.g. 'App') + * @param fileRelPath Path of the file relative to the group (e.g. 'SceneDelegate.swift') + */ +export function addSwiftFileToAppTarget( + pbxprojPath: string, + groupName: string, + fileRelPath: string, +): { added: boolean } { + const project = loadXcodeProject(pbxprojPath); + project.parseSync(); + + if (project.hasFile(fileRelPath)) { + return { added: false }; + } + + const groupUuid = findGroupUuidByComment(project, groupName); + if (!groupUuid) { + throw new Error(`Could not find PBXGroup with comment "${groupName}" in ${pbxprojPath}`); + } + + const targetUuid = project.getFirstTarget().uuid; + const result = project.addSourceFile(fileRelPath, { target: targetUuid }, groupUuid); + if (!result) { + throw new Error(`Failed to register ${fileRelPath} in ${pbxprojPath}`); + } + + writeFileSync(pbxprojPath, project.writeSync(), 'utf-8'); + return { added: true }; +} + +// 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) { + return key.substring(0, key.length - COMMENT_SUFFIX.length); + } + } + return null; +} diff --git a/cli/test/migrate-uiscene-detect.spec.ts b/cli/test/migrate-uiscene-detect.spec.ts new file mode 100644 index 000000000..4c73819e2 --- /dev/null +++ b/cli/test/migrate-uiscene-detect.spec.ts @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..dc3bd2513 --- /dev/null +++ b/cli/test/migrate-uiscene-plist.spec.ts @@ -0,0 +1,159 @@ +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-scan.spec.ts b/cli/test/migrate-uiscene-scan.spec.ts new file mode 100644 index 000000000..c0d7964dd --- /dev/null +++ b/cli/test/migrate-uiscene-scan.spec.ts @@ -0,0 +1,167 @@ +import { mkdirp, writeFileSync } from 'fs-extra'; +import { join } from 'path'; + +import type { Config } from '../src/definitions'; +import { logger } from '../src/log'; +import { __testables } from '../src/tasks/migrate-uiscene'; + +import { mktmp } from './util'; + +const { hasCustomDelegateBody, scanAndWarn } = __testables; + +const OPEN_URL_SIG = /func application\([^)]*\bopen url:/; +const CONTINUE_SIG = /func application\([^)]*\bcontinue userActivity:/; + +const VANILLA_OPEN_URL = ` +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } +} +`; + +const CUSTOM_OPEN_URL = ` +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + print("received url: \\(url)") + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } +} +`; + +const VANILLA_CONTINUE = ` +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } +} +`; + +const CUSTOM_CONTINUE = ` +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + MyAnalytics.track(userActivity) + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } +} +`; + +describe('hasCustomDelegateBody', () => { + it('returns false for the vanilla open-url body', () => { + expect(hasCustomDelegateBody(VANILLA_OPEN_URL, OPEN_URL_SIG)).toBe(false); + }); + + it('returns true for an open-url body with a print statement', () => { + expect(hasCustomDelegateBody(CUSTOM_OPEN_URL, OPEN_URL_SIG)).toBe(true); + }); + + it('returns false for the vanilla continue userActivity body', () => { + expect(hasCustomDelegateBody(VANILLA_CONTINUE, CONTINUE_SIG)).toBe(false); + }); + + it('returns true for a continue userActivity body with extra work', () => { + expect(hasCustomDelegateBody(CUSTOM_CONTINUE, CONTINUE_SIG)).toBe(true); + }); + + it('returns false when the method is not present', () => { + expect(hasCustomDelegateBody('class AppDelegate {}', OPEN_URL_SIG)).toBe(false); + }); + + it('ignores line comments inside the body', () => { + const source = ` +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // This comment is fine, feel free to add processing. + // Second comment line. + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } +} +`; + expect(hasCustomDelegateBody(source, OPEN_URL_SIG)).toBe(false); + }); +}); + +describe('scanAndWarn', () => { + let tmpDir: any; + let iosDir: string; + let appDir: string; + let warnSpy: jest.SpyInstance; + let infoSpy: jest.SpyInstance; + + beforeEach(async () => { + tmpDir = await mktmp(); + iosDir = join(tmpDir.path, 'ios'); + appDir = join(iosDir, 'App', 'App'); + await mkdirp(appDir); + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + infoSpy.mockRestore(); + tmpDir.cleanupCallback(); + }); + + function makeConfig(): Config { + return { + ios: { platformDirAbs: iosDir, nativeTargetDirAbs: appDir }, + } as unknown as Config; + } + + it('warns on UIApplication.shared.applicationState references', async () => { + writeFileSync( + join(appDir, 'SomeVC.swift'), + `import UIKit\nclass Foo {\n func check() {\n if UIApplication.shared.applicationState == .active { print("hi") }\n }\n}\n`, + ); + + await scanAndWarn(makeConfig()); + + const messages = warnSpy.mock.calls.map((c) => c[0]); + expect(messages.some((m) => m.includes('applicationState'))).toBe(true); + expect(messages.some((m) => m.includes('SomeVC.swift:4'))).toBe(true); + }); + + it('warns on tmpWindow and TmpViewController references', async () => { + writeFileSync(join(appDir, 'Legacy.swift'), `let w = tmpWindow\nlet vc: TmpViewController? = nil\n`); + + await scanAndWarn(makeConfig()); + + const messages = warnSpy.mock.calls.map((c) => c[0]); + expect(messages.some((m) => m.includes('tmpWindow'))).toBe(true); + expect(messages.some((m) => m.includes('TmpViewController'))).toBe(true); + }); + + it('warns on custom AppDelegate handlers', async () => { + writeFileSync(join(appDir, 'AppDelegate.swift'), CUSTOM_OPEN_URL + CUSTOM_CONTINUE); + + await scanAndWarn(makeConfig()); + + const messages = warnSpy.mock.calls.map((c) => c[0]); + expect(messages.some((m) => m.includes('custom application(_:open:) body'))).toBe(true); + expect(messages.some((m) => m.includes('custom application(_:continue:) body'))).toBe(true); + }); + + it('is silent on a clean project', async () => { + writeFileSync(join(appDir, 'AppDelegate.swift'), VANILLA_OPEN_URL + VANILLA_CONTINUE); + writeFileSync(join(appDir, 'CleanCode.swift'), `class Clean {}\n`); + + await scanAndWarn(makeConfig()); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('skips Pods/, build/, and DerivedData/ directories', async () => { + const podsDir = join(iosDir, 'App', 'Pods'); + const buildDir = join(iosDir, 'App', 'build'); + await mkdirp(podsDir); + await mkdirp(buildDir); + writeFileSync(join(podsDir, 'ThirdParty.swift'), `let x = UIApplication.shared.applicationState\n`); + writeFileSync(join(buildDir, 'Generated.swift'), `class TmpViewController {}\n`); + + await scanAndWarn(makeConfig()); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/test/migrate-uiscene-scene-delegate.spec.ts b/cli/test/migrate-uiscene-scene-delegate.spec.ts new file mode 100644 index 000000000..b2f400588 --- /dev/null +++ b/cli/test/migrate-uiscene-scene-delegate.spec.ts @@ -0,0 +1,98 @@ +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 new file mode 100644 index 000000000..e6aa08357 --- /dev/null +++ b/cli/test/xcode.spec.ts @@ -0,0 +1,114 @@ +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 { 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 { + return source + .split('\n') + .filter((line) => !line.includes('SceneDelegate')) + .join('\n'); +} + +describe('findGroupUuidByComment', () => { + it('returns the UUID of the App group in the shipped pbxproj', () => { + const project = loadXcodeProject(SHIPPED_PBXPROJ); + project.parseSync(); + + const uuid = findGroupUuidByComment(project, 'App'); + + expect(uuid).toMatch(/^[A-F0-9]{24}$/); + const group = project.getPBXGroupByKey(uuid!); + expect(group).toBeDefined(); + expect(group?.path).toBe('App'); + }); + + it('returns null for a group name that does not exist', () => { + const project = loadXcodeProject(SHIPPED_PBXPROJ); + project.parseSync(); + + expect(findGroupUuidByComment(project, 'NonExistentGroup')).toBeNull(); + }); +}); + +describe('addSwiftFileToAppTarget', () => { + let tmpDir: any; + let pbxprojPath: string; + + beforeEach(async () => { + tmpDir = await mktmp(); + pbxprojPath = join(tmpDir.path, 'project.pbxproj'); + }); + + afterEach(() => { + tmpDir.cleanupCallback(); + }); + + it('registers a new Swift file in all four pbxproj sections', () => { + const preUISceneSource = stripSceneDelegate(readFileSync(SHIPPED_PBXPROJ, 'utf-8')); + writeFileSync(pbxprojPath, preUISceneSource); + + const result = addSwiftFileToAppTarget(pbxprojPath, 'App', 'SceneDelegate.swift'); + + expect(result.added).toBe(true); + + // Reparse the written file and verify each section. + const project = loadXcodeProject(pbxprojPath); + project.parseSync(); + + 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( + 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')), + ).toBe(true); + + const appGroupUuid = findGroupUuidByComment(project, 'App')!; + const appGroup = project.getPBXGroupByKey(appGroupUuid)!; + expect(appGroup.children.some((c: any) => c.comment === 'SceneDelegate.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); + }); + + 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 after = readFileSync(pbxprojPath, 'utf-8'); + + expect(result.added).toBe(false); + expect(after).toBe(before); + }); + + it('is idempotent across repeated runs', () => { + const preUISceneSource = stripSceneDelegate(readFileSync(SHIPPED_PBXPROJ, 'utf-8')); + writeFileSync(pbxprojPath, preUISceneSource); + + 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); + }); + + it('throws when the target group cannot be found', () => { + writeFileSync(pbxprojPath, stripSceneDelegate(readFileSync(SHIPPED_PBXPROJ, 'utf-8'))); + + expect(() => addSwiftFileToAppTarget(pbxprojPath, 'DoesNotExist', 'SceneDelegate.swift')).toThrow( + /Could not find PBXGroup/, + ); + }); +});