diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj index eb67042..5bb25a4 100644 --- a/ios/app.xcodeproj/project.pbxproj +++ b/ios/app.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 82AA7221D96D78101B265EA1 /* Pods_app.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A2FE34E4E96E79AB979AE79F /* Pods_app.framework */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; + F1A5CE0D2EA0000000000002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A5CE0D2EA0000000000001 /* SceneDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -31,6 +32,7 @@ ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = app/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* app-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "app-Bridging-Header.h"; path = "app/app-Bridging-Header.h"; sourceTree = ""; }; + F1A5CE0D2EA0000000000001 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = app/SceneDelegate.swift; sourceTree = ""; }; FFC3E90B83FE5881E7191A28 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = app/PrivacyInfo.xcprivacy; sourceTree = ""; }; /* End PBXFileReference section */ @@ -50,6 +52,7 @@ isa = PBXGroup; children = ( F11748412D0307B40044C1D9 /* AppDelegate.swift */, + F1A5CE0D2EA0000000000001 /* SceneDelegate.swift */, F11748442D0722820044C1D9 /* app-Bridging-Header.h */, BB2F792B24A3F905000567C9 /* Supporting */, 13B07FB51A68108700A75B9A /* Images.xcassets */, @@ -403,6 +406,7 @@ buildActionMask = 2147483647; files = ( F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, + F1A5CE0D2EA0000000000002 /* SceneDelegate.swift in Sources */, 4EB67CDF4FA2FA6DB261C2AF /* ExpoModulesProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/app/Info.plist b/ios/app/Info.plist index 2d7ec17..923fdb6 100644 --- a/ios/app/Info.plist +++ b/ios/app/Info.plist @@ -59,6 +59,23 @@ 동아리 지원 시 입력한 이름과 전화번호를 동아리 관리자에게 전달하는 용도로만 사용됩니다. RCTNewArchEnabled + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + UILaunchStoryboardName SplashScreen UIRequiredDeviceCapabilities diff --git a/ios/app/SceneDelegate.swift b/ios/app/SceneDelegate.swift new file mode 100644 index 0000000..2bd3582 --- /dev/null +++ b/ios/app/SceneDelegate.swift @@ -0,0 +1,53 @@ +import UIKit + +// iOS 27 SDK부터 UIScene 생명주기가 필수라 추가한 얇은 어댑터. +// React Native는 AppDelegate가 띄운 window를 그대로 쓰고, URL 진입은 AppDelegate로 위임해 +// firebaseauth 필터 → Expo 구독자(expo-linking, expo-router) → RCTLinkingManager 순서를 유지한다. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard + let windowScene = scene as? UIWindowScene, + let appWindow = (UIApplication.shared.delegate as? AppDelegate)?.window + else { + return + } + + appWindow.windowScene = windowScene + appWindow.makeKeyAndVisible() + window = appWindow + + // Scene 생명주기에서는 cold start URL이 launchOptions가 아니라 connectionOptions로 들어온다. + self.scene(scene, openURLContexts: connectionOptions.urlContexts) + for userActivity in connectionOptions.userActivities { + self.scene(scene, continue: userActivity) + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { + return + } + + for context in URLContexts { + var options: [UIApplication.OpenURLOptionsKey: Any] = [:] + if let sourceApplication = context.options.sourceApplication { + options[.sourceApplication] = sourceApplication + } + _ = appDelegate.application(UIApplication.shared, open: context.url, options: options) + } + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { + return + } + + _ = appDelegate.application(UIApplication.shared, continue: userActivity, restorationHandler: { _ in }) + } +} diff --git a/plugins/ios-prebuild-support/SceneDelegate.swift b/plugins/ios-prebuild-support/SceneDelegate.swift new file mode 100644 index 0000000..2bd3582 --- /dev/null +++ b/plugins/ios-prebuild-support/SceneDelegate.swift @@ -0,0 +1,53 @@ +import UIKit + +// iOS 27 SDK부터 UIScene 생명주기가 필수라 추가한 얇은 어댑터. +// React Native는 AppDelegate가 띄운 window를 그대로 쓰고, URL 진입은 AppDelegate로 위임해 +// firebaseauth 필터 → Expo 구독자(expo-linking, expo-router) → RCTLinkingManager 순서를 유지한다. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard + let windowScene = scene as? UIWindowScene, + let appWindow = (UIApplication.shared.delegate as? AppDelegate)?.window + else { + return + } + + appWindow.windowScene = windowScene + appWindow.makeKeyAndVisible() + window = appWindow + + // Scene 생명주기에서는 cold start URL이 launchOptions가 아니라 connectionOptions로 들어온다. + self.scene(scene, openURLContexts: connectionOptions.urlContexts) + for userActivity in connectionOptions.userActivities { + self.scene(scene, continue: userActivity) + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { + return + } + + for context in URLContexts { + var options: [UIApplication.OpenURLOptionsKey: Any] = [:] + if let sourceApplication = context.options.sourceApplication { + options[.sourceApplication] = sourceApplication + } + _ = appDelegate.application(UIApplication.shared, open: context.url, options: options) + } + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { + return + } + + _ = appDelegate.application(UIApplication.shared, continue: userActivity, restorationHandler: { _ in }) + } +} diff --git a/plugins/withIosPrebuildFixes.js b/plugins/withIosPrebuildFixes.js index fd4fa57..2d1a073 100644 --- a/plugins/withIosPrebuildFixes.js +++ b/plugins/withIosPrebuildFixes.js @@ -1,9 +1,23 @@ const fs = require('fs'); const path = require('path'); -const { withFinalizedMod, withPodfile, withXcodeProject } = require('@expo/config-plugins'); +const { IOSConfig, withFinalizedMod, withInfoPlist, withPodfile, withXcodeProject } = require('@expo/config-plugins'); const SUPPORT_DIR = path.join(__dirname, 'ios-prebuild-support'); +// iOS 27 SDK부터 UIScene 생명주기가 필수다. SceneDelegate는 AppDelegate의 window와 URL 처리를 그대로 재사용한다. +const SCENE_DELEGATE_FILE = 'SceneDelegate.swift'; +const SCENE_MANIFEST = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: 'Default Configuration', + UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate', + }, + ], + }, +}; + const RNFB_SCRIPT_PHASE_NAMES = new Set([ '[CP-User] [RNFB] Core Configuration', '[CP-User] [RNFB] Crashlytics Configuration', @@ -143,7 +157,12 @@ function normalizeXcodeString(value) { .replace(/\\"/g, '"'); } -function applyXcodeProjectFixes(project) { +function applyXcodeProjectFixes(project, projectName) { + const sceneDelegatePath = `${projectName}/${SCENE_DELEGATE_FILE}`; + if (!project.hasFile(sceneDelegatePath)) { + IOSConfig.XcodeUtils.addBuildSourceFileToGroup({ filepath: sceneDelegatePath, groupName: projectName, project }); + } + const shellScriptBuildPhases = project.hash?.project?.objects?.PBXShellScriptBuildPhase; for (const phase of Object.values(shellScriptBuildPhases || {})) { @@ -179,7 +198,8 @@ function removeAppTestsFromScheme(schemeContents) { }); } -function applyFinalizedFileFixes(platformProjectRoot) { +function applyFinalizedFileFixes(platformProjectRoot, projectName) { + copySupportFile(SCENE_DELEGATE_FILE, path.join(platformProjectRoot, projectName, SCENE_DELEGATE_FILE)); copySupportFile('xcode.env', path.join(platformProjectRoot, '.xcode.env')); copySupportFile('ci_post_clone.sh', path.join(platformProjectRoot, 'ci_scripts', 'ci_post_clone.sh'), 0o755); copySupportFile( @@ -200,15 +220,20 @@ module.exports = function withIosPrebuildFixes(config) { return config; }); + config = withInfoPlist(config, (config) => { + config.modResults.UIApplicationSceneManifest = SCENE_MANIFEST; + return config; + }); + config = withXcodeProject(config, (config) => { - config.modResults = applyXcodeProjectFixes(config.modResults); + config.modResults = applyXcodeProjectFixes(config.modResults, config.modRequest.projectName); return config; }); return withFinalizedMod(config, [ 'ios', (config) => { - applyFinalizedFileFixes(config.modRequest.platformProjectRoot); + applyFinalizedFileFixes(config.modRequest.platformProjectRoot, config.modRequest.projectName); return config; }, ]);