From 46313ac4157887295bc7c75cb448e945f47a830a Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 26 Jul 2026 11:44:53 +0900 Subject: [PATCH 01/12] =?UTF-8?q?chore:=20GoogleSignIn=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=EA=B3=BC=20=EC=95=B1=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Sources/App/Delegate/AppDelegate.swift | 9 +++++++++ Application/App/Sources/Resource/Info.plist | 5 +++++ .../SocialLogin/GoogleSignInURLHandler.swift | 15 +++++++++++++++ README.md | 2 +- .../Project+Packages.swift | 6 ++++++ 5 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift diff --git a/Application/App/Sources/App/Delegate/AppDelegate.swift b/Application/App/Sources/App/Delegate/AppDelegate.swift index 2c34c8fb..ca1daa5f 100644 --- a/Application/App/Sources/App/Delegate/AppDelegate.swift +++ b/Application/App/Sources/App/Delegate/AppDelegate.swift @@ -15,6 +15,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private let logger = Logger(category: "AppDelegate") private let container = AppDIContainer.shared + // Google 로그인 URL 콜백 처리 + func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + GoogleSignInURLHandler.handle(url) + } + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil diff --git a/Application/App/Sources/Resource/Info.plist b/Application/App/Sources/Resource/Info.plist index 78831b61..99ef65df 100644 --- a/Application/App/Sources/Resource/Info.plist +++ b/Application/App/Sources/Resource/Info.plist @@ -31,6 +31,7 @@ Editor CFBundleURLSchemes + $(REVERSED_CLIENT_ID) DevLog @@ -53,6 +54,10 @@ $(FIRESTORE_DATABASE_ID) FUNCTION_API_BASE_URL $(FUNCTION_API_BASE_URL) + GIDClientID + $(CLIENT_ID) + GIDServerClientID + $(GID_SERVER_CLIENT_ID) ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift new file mode 100644 index 00000000..e7da0214 --- /dev/null +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleSignInURLHandler.swift @@ -0,0 +1,15 @@ +// +// GoogleSignInURLHandler.swift +// Infra +// +// Created by opfic on 7/26/26. +// + +import Foundation +import GoogleSignIn + +public enum GoogleSignInURLHandler { + public static func handle(_ url: URL) -> Bool { + GIDSignIn.sharedInstance.handle(url) + } +} diff --git a/README.md b/README.md index bfb146e0..a0317739 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Todo, 저장 링크, 오늘 할 일, 받은 알림, 누적 활동을 하나의 | Backend | Firebase Authentication, Firestore, Cloud Functions, Cloud Messaging | | Monitoring | Firebase Analytics, Crashlytics | | Apple Frameworks | AuthenticationServices, UserNotifications, LinkPresentation, Network, CryptoKit, os.log | -| External Packages | ComposableArchitecture, OrderedCollections, Nexa | +| External Packages | ComposableArchitecture, OrderedCollections, GoogleSignIn, Nexa | | Testing | swift-testing, TCA TestStore | | Tooling | Xcode, Tuist, mise, Swift Package Manager, SwiftLint, Fastlane | diff --git a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift index fc47cd49..1b6657cc 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift @@ -13,6 +13,10 @@ public enum DevLogPackages { url: "https://github.com/firebase/firebase-ios-sdk", .exact("11.15.0") ) + public static let googleSignInPackage: Package = .package( + url: "https://github.com/google/GoogleSignIn-iOS", + .revision("02616ac6b469e8f00212436d2cac16e6efad7954") + ) public static let nexaPackage: Package = .package( url: "https://github.com/opficdev/Nexa", .upToNextMinor(from: "1.1.1") @@ -31,6 +35,7 @@ public enum DevLogPackages { .package(product: "FirebaseCrashlytics"), .package(product: "FirebaseMessaging"), .package(product: "FirebaseFirestore"), + .package(product: "GoogleSignIn"), .package(product: "Nexa"), ] @@ -43,6 +48,7 @@ public enum DevLogPackages { public static let infraPackages: [Package] = [ firebasePackage, + googleSignInPackage, nexaPackage, ] } From e50c14e424626754281c9754acd4d49d994bf275 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 26 Jul 2026 11:59:02 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20Google=20authorization=20code=20A?= =?UTF-8?q?PI=20=EA=B3=84=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Service/FunctionAPIClient.swift | 4 +++ .../Sources/Service/FunctionAPIEndpoint.swift | 8 ++++++ .../Service/FunctionAPIEndpointTests.swift | 27 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/Application/Infra/Sources/Service/FunctionAPIClient.swift b/Application/Infra/Sources/Service/FunctionAPIClient.swift index 4ed24fb4..1658d0ec 100644 --- a/Application/Infra/Sources/Service/FunctionAPIClient.swift +++ b/Application/Infra/Sources/Service/FunctionAPIClient.swift @@ -112,6 +112,10 @@ struct OAuthAuthenticationTicketRequest: Encodable { let appVerifier: String } +struct GoogleAuthorizationCodeRequest: Encodable { + let serverAuthCode: String +} + struct AppleChallengeResponse: Decodable { let challengeId: String let hashedNonce: String diff --git a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift index 070eb6d0..5a24bcb2 100644 --- a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift +++ b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift @@ -39,6 +39,10 @@ extension FunctionAPIEndpoint where Response == EmptyAPIResponse { static let unlinkGithubAccount = Self(method: .delete, path: "/auth/github/account-link") static let revokeGoogleAccessToken = Self(method: .delete, path: "/auth/google/access-token") static let linkGoogleAccount = Self(method: .put, path: "/auth/google/account-link") + static let linkGoogleAccountWithAuthorizationCode = Self( + method: .put, + path: "/auth/google/authorization-code/account-link" + ) static let unlinkGoogleAccount = Self(method: .delete, path: "/auth/google/account-link") static let linkAppleAccount = Self(method: .put, path: "/auth/apple/account-link") static let unlinkAppleAccount = Self(method: .delete, path: "/auth/apple/account-link") @@ -53,6 +57,10 @@ extension FunctionAPIEndpoint where Response == FirebaseCustomTokenResponse { static let requestAppleCustomToken = Self(method: .post, path: "/auth/apple/custom-token") static let requestGithubCustomToken = Self(method: .post, path: "/auth/github/custom-token") static let requestGoogleCustomToken = Self(method: .post, path: "/auth/google/custom-token") + static let requestGoogleAuthorizationCustomToken = Self( + method: .post, + path: "/auth/google/authorization-code/custom-token" + ) } extension FunctionAPIEndpoint where Response == OAuthAuthenticationSessionResponse { diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift index fffd87d3..70b29ae7 100644 --- a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift +++ b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift @@ -217,6 +217,15 @@ struct FunctionAPIEndpointTests { #expect(try encodedKeys(request) == ["ticket", "appVerifier"]) } + @Test("Google authorization code 요청은 server auth code만 인코딩한다") + func Google_authorization_code_요청은_server_auth_code만_인코딩한다() throws { + let request = GoogleAuthorizationCodeRequest( + serverAuthCode: "server-auth-code" + ) + + #expect(try encodedKeys(request) == ["serverAuthCode"]) + } + @Test("GitHub 로그인 session endpoint는 sign-in-sessions 경로를 사용한다") func GitHub_로그인_session_endpoint는_sign_in_sessions_경로를_사용한다() { let endpoint = FunctionAPIEndpoint @@ -278,6 +287,15 @@ struct FunctionAPIEndpointTests { #expect(endpoint.path == "/auth/google/custom-token") } + @Test("Google authorization code custom token endpoint는 인증 코드 경로를 사용한다") + func Google_authorization_code_custom_token_endpoint는_인증_코드_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint + .requestGoogleAuthorizationCustomToken + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/google/authorization-code/custom-token") + } + @Test("Google 계정 연결 session endpoint는 account-link-sessions 경로를 사용한다") func Google_계정_연결_session_endpoint는_account_link_sessions_경로를_사용한다() { let endpoint = FunctionAPIEndpoint @@ -295,6 +313,15 @@ struct FunctionAPIEndpointTests { #expect(endpoint.path == "/auth/google/account-link") } + @Test("Google authorization code 계정 연결 endpoint는 PUT 인증 코드 경로를 사용한다") + func Google_authorization_code_계정_연결_endpoint는_PUT_인증_코드_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint + .linkGoogleAccountWithAuthorizationCode + + #expect(endpoint.method.rawValue == "PUT") + #expect(endpoint.path == "/auth/google/authorization-code/account-link") + } + @Test("Google 계정 연결 해제 endpoint는 DELETE account-link 경로를 사용한다") func Google_계정_연결_해제_endpoint는_DELETE_account_link_경로를_사용한다() { let endpoint = FunctionAPIEndpoint.unlinkGoogleAccount From 4ff3a87f4d543425958fe3c4815ae1126eaca6fd Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 26 Jul 2026 15:08:08 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20Google=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EC=97=90=20serverAuthCode=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Common/InfraLayerError.swift | 7 ++- .../Common/TopViewControllerProvider.swift | 39 +++++++++++++++ .../GoogleAuthenticationServiceImpl.swift | 30 ++++++++--- .../Tests/Common/SocialLoginErrorTests.swift | 32 ++++++++++++ .../TopViewControllerProviderTests.swift | 50 +++++++++++++++++++ 5 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 Application/Infra/Sources/Common/TopViewControllerProvider.swift create mode 100644 Application/Infra/Tests/Common/SocialLoginErrorTests.swift create mode 100644 Application/Infra/Tests/Common/TopViewControllerProviderTests.swift diff --git a/Application/Infra/Sources/Common/InfraLayerError.swift b/Application/Infra/Sources/Common/InfraLayerError.swift index c9892578..437efe78 100644 --- a/Application/Infra/Sources/Common/InfraLayerError.swift +++ b/Application/Infra/Sources/Common/InfraLayerError.swift @@ -19,6 +19,10 @@ enum FirestoreError: Error, LocalizedError { } } +enum UIError: Error { + case notFoundTopViewController +} + enum SocialLoginError: Error { case invalidOAuthCallback case failedToStartWebAuthenticationSession @@ -40,7 +44,8 @@ extension Error { case let webAuthError as ASWebAuthenticationSessionError: return webAuthError.code == .canceledLogin default: - return false + let error = self as NSError + return error.domain == "com.google.GIDSignIn" && error.code == -5 } } } diff --git a/Application/Infra/Sources/Common/TopViewControllerProvider.swift b/Application/Infra/Sources/Common/TopViewControllerProvider.swift new file mode 100644 index 00000000..63ffe3ef --- /dev/null +++ b/Application/Infra/Sources/Common/TopViewControllerProvider.swift @@ -0,0 +1,39 @@ +// +// TopViewControllerProvider.swift +// Infra +// +// Created by opfic on 7/26/26. +// + +import UIKit + +enum TopViewControllerProvider { + @MainActor + static func topViewController() -> UIViewController? { + let rootViewController = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .rootViewController + + return topViewController(from: rootViewController) + } + + @MainActor + static func topViewController(from viewController: UIViewController?) -> UIViewController? { + if let navigationController = viewController as? UINavigationController { + return topViewController(from: navigationController.visibleViewController) + } + + if let tabBarController = viewController as? UITabBarController, + let selectedViewController = tabBarController.selectedViewController { + return topViewController(from: selectedViewController) + } + + if let presentedViewController = viewController?.presentedViewController { + return topViewController(from: presentedViewController) + } + + return viewController + } +} diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 44aae388..84200d9e 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -8,6 +8,8 @@ import FirebaseAuth import FirebaseFirestore import FirebaseMessaging +import Foundation +import GoogleSignIn import Core import Data @@ -33,13 +35,12 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { logger.info("Starting Google sign in") do { - let request = try await OAuthAuthenticationTicketRequester.request( - endpoint: .requestGoogleSignInSession, - requiresAuthentication: false - ) + let serverAuthCode = try await Self.requestGoogleServerAuthCode() let response = try await FunctionAPIClient.shared.send( - .requestGoogleCustomToken, - payload: request, + .requestGoogleAuthorizationCustomToken, + payload: GoogleAuthorizationCodeRequest( + serverAuthCode: serverAuthCode + ), requiresAuthentication: false ) @@ -130,6 +131,23 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { } private extension GoogleAuthenticationServiceImpl { + @MainActor + static func requestGoogleServerAuthCode() async throws -> String { + guard let topViewController = TopViewControllerProvider.topViewController() else { + throw UIError.notFoundTopViewController + } + + let signIn = try await GIDSignIn.sharedInstance.signIn( + withPresenting: topViewController + ) + + guard let serverAuthCode = signIn.serverAuthCode else { + throw URLError(.badServerResponse) + } + + return serverAuthCode + } + func mapGoogleAPIError(_ error: Error) -> Error { if let emailError = error.apiEmailError { return emailError diff --git a/Application/Infra/Tests/Common/SocialLoginErrorTests.swift b/Application/Infra/Tests/Common/SocialLoginErrorTests.swift new file mode 100644 index 00000000..2b098e5f --- /dev/null +++ b/Application/Infra/Tests/Common/SocialLoginErrorTests.swift @@ -0,0 +1,32 @@ +// +// SocialLoginErrorTests.swift +// InfraTests +// +// Created by opfic on 7/26/26. +// + +import Foundation +import Testing +@testable import Infra + +struct SocialLoginErrorTests { + @Test("Google 로그인 취소 오류를 소셜 로그인 취소로 분류한다") + func Google_로그인_취소_오류를_소셜_로그인_취소로_분류한다() { + let error = NSError( + domain: "com.google.GIDSignIn", + code: -5 + ) + + #expect(error.isSocialLoginCancelled) + } + + @Test("Google 로그인의 다른 오류를 취소로 분류하지 않는다") + func Google_로그인의_다른_오류를_취소로_분류하지_않는다() { + let error = NSError( + domain: "com.google.GIDSignIn", + code: -1 + ) + + #expect(!error.isSocialLoginCancelled) + } +} diff --git a/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift b/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift new file mode 100644 index 00000000..3857f781 --- /dev/null +++ b/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift @@ -0,0 +1,50 @@ +// +// TopViewControllerProviderTests.swift +// InfraTests +// +// Created by opfic on 7/26/26. +// + +import Testing +import UIKit +@testable import Infra + +@MainActor +struct TopViewControllerProviderTests { + @Test("Navigation의 표시 화면을 반환한다") + func Navigation의_표시_화면을_반환한다() { + let rootViewController = UIViewController() + let visibleViewController = UIViewController() + let navigationController = UINavigationController( + rootViewController: rootViewController + ) + navigationController.pushViewController( + visibleViewController, + animated: false + ) + + let result = TopViewControllerProvider.topViewController( + from: navigationController + ) + + #expect(result === visibleViewController) + } + + @Test("Tab의 선택 화면을 반환한다") + func Tab의_선택_화면을_반환한다() { + let firstViewController = UIViewController() + let selectedViewController = UIViewController() + let tabBarController = UITabBarController() + tabBarController.viewControllers = [ + firstViewController, + selectedViewController + ] + tabBarController.selectedViewController = selectedViewController + + let result = TopViewControllerProvider.topViewController( + from: tabBarController + ) + + #expect(result === selectedViewController) + } +} From 146cfbfe486b703da622f19da5ab49e92869f059 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 26 Jul 2026 17:11:58 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20Google=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EC=97=90=20serverAuthCode=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Service/FunctionAPIEndpoint.swift | 3 +-- .../GoogleAuthenticationServiceImpl.swift | 16 ++++++++++------ .../Tests/Service/FunctionAPIEndpointTests.swift | 13 ++----------- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift index 5a24bcb2..e5fe23b9 100644 --- a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift +++ b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift @@ -38,8 +38,7 @@ extension FunctionAPIEndpoint where Response == EmptyAPIResponse { static let linkGithubAccount = Self(method: .put, path: "/auth/github/account-link") static let unlinkGithubAccount = Self(method: .delete, path: "/auth/github/account-link") static let revokeGoogleAccessToken = Self(method: .delete, path: "/auth/google/access-token") - static let linkGoogleAccount = Self(method: .put, path: "/auth/google/account-link") - static let linkGoogleAccountWithAuthorizationCode = Self( + static let linkGoogleAccount = Self( method: .put, path: "/auth/google/authorization-code/account-link" ) diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 84200d9e..72a5a062 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -94,13 +94,10 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { logger.info("Linking Google account for user: \(uid)") do { - let request = try await OAuthAuthenticationTicketRequester.request( - endpoint: .requestGoogleAccountLinkSession, - requiresAuthentication: true - ) + let serverAuthCode = try await Self.requestGoogleServerAuthCode(signOutPreviousSession: true) try await FunctionAPIClient.shared.send( .linkGoogleAccount, - payload: request + payload: GoogleAuthorizationCodeRequest(serverAuthCode: serverAuthCode) ) try await user?.reload() @@ -132,11 +129,18 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { private extension GoogleAuthenticationServiceImpl { @MainActor - static func requestGoogleServerAuthCode() async throws -> String { + static func requestGoogleServerAuthCode( + signOutPreviousSession: Bool = false + ) async throws -> String { guard let topViewController = TopViewControllerProvider.topViewController() else { throw UIError.notFoundTopViewController } + if signOutPreviousSession, + GIDSignIn.sharedInstance.hasPreviousSignIn() { + GIDSignIn.sharedInstance.signOut() + } + let signIn = try await GIDSignIn.sharedInstance.signIn( withPresenting: topViewController ) diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift index 70b29ae7..fe7124eb 100644 --- a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift +++ b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift @@ -305,19 +305,10 @@ struct FunctionAPIEndpointTests { #expect(endpoint.path == "/auth/google/account-link-sessions") } - @Test("Google 계정 연결 endpoint는 PUT account-link 경로를 사용한다") - func Google_계정_연결_endpoint는_PUT_account_link_경로를_사용한다() { + @Test("Google 계정 연결 endpoint는 PUT 인증 코드 경로를 사용한다") + func Google_계정_연결_endpoint는_PUT_인증_코드_경로를_사용한다() { let endpoint = FunctionAPIEndpoint.linkGoogleAccount - #expect(endpoint.method.rawValue == "PUT") - #expect(endpoint.path == "/auth/google/account-link") - } - - @Test("Google authorization code 계정 연결 endpoint는 PUT 인증 코드 경로를 사용한다") - func Google_authorization_code_계정_연결_endpoint는_PUT_인증_코드_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint - .linkGoogleAccountWithAuthorizationCode - #expect(endpoint.method.rawValue == "PUT") #expect(endpoint.path == "/auth/google/authorization-code/account-link") } From 7a35bca147298f31ebb90f3cae1fa0b224bca31b Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 26 Jul 2026 17:43:10 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20Google=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=8B=9C=20=EB=A1=9C=EC=BB=AC=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/SocialLogin/GoogleAuthenticationServiceImpl.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 72a5a062..2de8f53c 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -73,6 +73,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { } try Auth.auth().signOut() + GIDSignIn.sharedInstance.signOut() } catch { logger.error("Failed to sign out with Google", error: error) record(error, code: .signOut) From f897e1703d7974a0d8ed05fc0ee29a8e44536b4a Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 26 Jul 2026 17:48:59 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20Google=20ticket=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9D=B8=EC=A6=9D=20=ED=98=B8=EC=B6=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Service/FunctionAPIEndpoint.swift | 9 ------- .../Service/FunctionAPIEndpointTests.swift | 27 ------------------- 2 files changed, 36 deletions(-) diff --git a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift index e5fe23b9..ec957c77 100644 --- a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift +++ b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift @@ -55,7 +55,6 @@ extension FunctionAPIEndpoint where Response == AppleChallengeResponse { extension FunctionAPIEndpoint where Response == FirebaseCustomTokenResponse { static let requestAppleCustomToken = Self(method: .post, path: "/auth/apple/custom-token") static let requestGithubCustomToken = Self(method: .post, path: "/auth/github/custom-token") - static let requestGoogleCustomToken = Self(method: .post, path: "/auth/google/custom-token") static let requestGoogleAuthorizationCustomToken = Self( method: .post, path: "/auth/google/authorization-code/custom-token" @@ -71,14 +70,6 @@ extension FunctionAPIEndpoint where Response == OAuthAuthenticationSessionRespon method: .post, path: "/auth/github/account-link-sessions" ) - static let requestGoogleSignInSession = Self( - method: .post, - path: "/auth/google/sign-in-sessions" - ) - static let requestGoogleAccountLinkSession = Self( - method: .post, - path: "/auth/google/account-link-sessions" - ) } private func functionAPIPathSegment(_ value: String) -> String { diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift index fe7124eb..0de446d9 100644 --- a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift +++ b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift @@ -269,24 +269,6 @@ struct FunctionAPIEndpointTests { #expect(endpoint.path == "/auth/github/account-link") } - @Test("Google 로그인 session endpoint는 sign-in-sessions 경로를 사용한다") - func Google_로그인_session_endpoint는_sign_in_sessions_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint - .requestGoogleSignInSession - - #expect(endpoint.method.rawValue == "POST") - #expect(endpoint.path == "/auth/google/sign-in-sessions") - } - - @Test("Google custom token endpoint는 custom-token 경로를 사용한다") - func Google_custom_token_endpoint는_custom_token_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint - .requestGoogleCustomToken - - #expect(endpoint.method.rawValue == "POST") - #expect(endpoint.path == "/auth/google/custom-token") - } - @Test("Google authorization code custom token endpoint는 인증 코드 경로를 사용한다") func Google_authorization_code_custom_token_endpoint는_인증_코드_경로를_사용한다() { let endpoint = FunctionAPIEndpoint @@ -296,15 +278,6 @@ struct FunctionAPIEndpointTests { #expect(endpoint.path == "/auth/google/authorization-code/custom-token") } - @Test("Google 계정 연결 session endpoint는 account-link-sessions 경로를 사용한다") - func Google_계정_연결_session_endpoint는_account_link_sessions_경로를_사용한다() { - let endpoint = FunctionAPIEndpoint - .requestGoogleAccountLinkSession - - #expect(endpoint.method.rawValue == "POST") - #expect(endpoint.path == "/auth/google/account-link-sessions") - } - @Test("Google 계정 연결 endpoint는 PUT 인증 코드 경로를 사용한다") func Google_계정_연결_endpoint는_PUT_인증_코드_경로를_사용한다() { let endpoint = FunctionAPIEndpoint.linkGoogleAccount From 21e7e5ecbc844b00f155b68e94632d51c882f750 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 26 Jul 2026 18:19:37 +0900 Subject: [PATCH 07/12] =?UTF-8?q?chore:=20Staging=EA=B3=BC=20Prod=20Google?= =?UTF-8?q?SignIn=20=EC=84=A4=EC=A0=95=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastlane/Fastfile | 131 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 4 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9d942244..f17a9646 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -113,6 +113,37 @@ rescue SystemCallError => error UI.user_error!("Could not read app configuration file at #{expanded_path}: #{error.message}") end +def expected_google_sign_in_configuration_from_xcconfig(configuration) + expanded_path = File.expand_path(APP_CONFIG_XCCONFIG_PATH) + UI.user_error!("Missing app configuration file: #{expanded_path}") if !File.file?(expanded_path) + + escaped_configuration = Regexp.escape(configuration) + expected_settings = { + client_id: "CLIENT_ID", + reversed_client_id: "REVERSED_CLIENT_ID", + server_client_id: "GID_SERVER_CLIENT_ID" + } + + expected_settings.to_h do |key, setting| + assignments = File.foreach(expanded_path).filter_map do |line| + assignment = line.match( + /^\s*#{setting}\s*\[\s*config\s*=\s*#{escaped_configuration}\s*\]\s*=\s*(.*)$/ + ) + next if assignment.nil? + + assignment[1].strip + end + + if assignments.empty? || assignments.last.empty? + UI.user_error!("Missing #{setting} for #{configuration} in #{expanded_path}") + end + + [key, assignments.last] + end +rescue SystemCallError => error + UI.user_error!("Could not read app configuration file at #{expanded_path}: #{error.message}") +end + def verify_function_api_base_url_policy(function_api_base_url) require "uri" @@ -225,7 +256,10 @@ platform :ios do app_environment: "app environment", bundle_id: "bundle ID", database_id: "Firestore database ID", - function_api_base_url: "Function API base URL" + function_api_base_url: "Function API base URL", + client_id: "Google client ID", + reversed_client_id: "Google reversed client ID", + server_client_id: "Google server client ID" } required_expectations.each do |key, name| @@ -285,6 +319,28 @@ platform :ios do redact_values: true ) + verify_plist_value( + path: plist_path, + source: "app Info.plist", + key: "GIDClientID", + expected: expected_configuration[:client_id], + redact_values: true + ) + + verify_plist_value( + path: plist_path, + source: "app Info.plist", + key: "GIDServerClientID", + expected: expected_configuration[:server_client_id], + redact_values: true + ) + + verify_plist_url_scheme( + path: plist_path, + source: "app Info.plist", + expected: expected_configuration[:reversed_client_id] + ) + verify_plist_value( path: plist_path, source: "app Info.plist", @@ -311,6 +367,22 @@ platform :ios do expected: expected_configuration[:bundle_id] ) + verify_plist_value( + path: google_service_info_plist_path, + source: "GoogleService-Info.plist", + key: "CLIENT_ID", + expected: expected_configuration[:client_id], + redact_values: true + ) + + verify_plist_value( + path: google_service_info_plist_path, + source: "GoogleService-Info.plist", + key: "REVERSED_CLIENT_ID", + expected: expected_configuration[:reversed_client_id], + redact_values: true + ) + if !expected_firebase_project_id.empty? && actual_firebase_project_id != expected_firebase_project_id UI.user_error!("Unexpected PROJECT_ID in GoogleService-Info.plist") end @@ -324,6 +396,45 @@ platform :ios do end end + private_lane :verify_plist_url_scheme do |options| + require "json" + require "shellwords" + + path = options[:path] + source = options[:source] + expected_value = options[:expected].to_s.strip + + UI.user_error!("Missing expected Google URL scheme") if expected_value.empty? + + url_types = begin + JSON.parse( + sh( + "/usr/bin/plutil -extract CFBundleURLTypes json -o - #{Shellwords.escape(path)}", + log: false + ) + ) + rescue StandardError + UI.user_error!("Missing CFBundleURLTypes in #{source} at #{path}") + end + + if !url_types.is_a?(Array) + UI.user_error!("Invalid CFBundleURLTypes in #{source} at #{path}") + end + + url_schemes = url_types.flat_map do |url_type| + next [] if !url_type.is_a?(Hash) + + schemes = url_type["CFBundleURLSchemes"] + schemes.is_a?(Array) ? schemes : [] + end + + if !url_schemes.include?(expected_value) + UI.user_error!("Missing expected Google URL scheme in #{source}") + end + + UI.message("Verified Google URL scheme in #{source}") + end + private_lane :required_plist_value do |options| require "shellwords" @@ -383,6 +494,7 @@ platform :ios do ) firebase_configuration = expected_firebase_configuration_from_xcconfig(configuration) + google_sign_in_configuration = expected_google_sign_in_configuration_from_xcconfig(configuration) if ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"].to_s.strip.empty? ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "30" @@ -476,7 +588,10 @@ platform :ios do database_id: expected_database_id, function_api_base_url: expected_function_api_base_url, firebase_project_id: firebase_configuration[:firebase_project_id], - google_app_id: firebase_configuration[:google_app_id] + google_app_id: firebase_configuration[:google_app_id], + client_id: google_sign_in_configuration[:client_id], + reversed_client_id: google_sign_in_configuration[:reversed_client_id], + server_client_id: google_sign_in_configuration[:server_client_id] } ) @@ -542,6 +657,7 @@ platform :ios do lane :upload_testflight_build do api_key = asc_api_key firebase_configuration = expected_firebase_configuration_from_xcconfig(TESTFLIGHT_CONFIGURATION) + google_sign_in_configuration = expected_google_sign_in_configuration_from_xcconfig(TESTFLIGHT_CONFIGURATION) # lane_context는 같은 fastlane 실행 내에서만 유지되므로, 별도 CI step에서는 고정 ipa 경로를 사용한다. ipa_output_path = lane_context[SharedValues::IPA_OUTPUT_PATH].to_s ipa_output_path = TESTFLIGHT_IPA_OUTPUT_PATH if ipa_output_path.empty? @@ -556,7 +672,10 @@ platform :ios do database_id: TESTFLIGHT_DATABASE_ID, function_api_base_url: function_api_base_url_from_xcconfig(TESTFLIGHT_CONFIGURATION), firebase_project_id: firebase_configuration[:firebase_project_id], - google_app_id: firebase_configuration[:google_app_id] + google_app_id: firebase_configuration[:google_app_id], + client_id: google_sign_in_configuration[:client_id], + reversed_client_id: google_sign_in_configuration[:reversed_client_id], + server_client_id: google_sign_in_configuration[:server_client_id] } ) @@ -570,6 +689,7 @@ platform :ios do lane :upload_appstore_build do api_key = asc_api_key firebase_configuration = expected_firebase_configuration_from_xcconfig(APPSTORE_CONFIGURATION) + google_sign_in_configuration = expected_google_sign_in_configuration_from_xcconfig(APPSTORE_CONFIGURATION) # lane_context는 같은 fastlane 실행 내에서만 유지되므로, 별도 CI step에서는 고정 ipa 경로를 사용한다. ipa_output_path = lane_context[SharedValues::IPA_OUTPUT_PATH].to_s ipa_output_path = APPSTORE_IPA_OUTPUT_PATH if ipa_output_path.empty? @@ -584,7 +704,10 @@ platform :ios do database_id: APPSTORE_DATABASE_ID, function_api_base_url: function_api_base_url_from_xcconfig(APPSTORE_CONFIGURATION), firebase_project_id: firebase_configuration[:firebase_project_id], - google_app_id: firebase_configuration[:google_app_id] + google_app_id: firebase_configuration[:google_app_id], + client_id: google_sign_in_configuration[:client_id], + reversed_client_id: google_sign_in_configuration[:reversed_client_id], + server_client_id: google_sign_in_configuration[:server_client_id] } ) From 78a1ff9335b05d8ef63dc1157835f304630d76f1 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 27 Jul 2026 00:13:40 +0900 Subject: [PATCH 08/12] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/SocialLogin/GoogleAuthenticationServiceImpl.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 2de8f53c..fe8edea5 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -142,9 +142,7 @@ private extension GoogleAuthenticationServiceImpl { GIDSignIn.sharedInstance.signOut() } - let signIn = try await GIDSignIn.sharedInstance.signIn( - withPresenting: topViewController - ) + let signIn = try await GIDSignIn.sharedInstance.signIn(withPresenting: topViewController) guard let serverAuthCode = signIn.serverAuthCode else { throw URLError(.badServerResponse) From 198c38e7aacbd53c6e1fb3bb58b132432ce94f69 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 27 Jul 2026 11:11:38 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20Google=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20scene=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuthPresentationContext.swift | 17 ++++++++ .../Common/TopViewControllerProvider.swift | 36 ++++++++++++++--- .../GoogleAuthenticationServiceImpl.swift | 7 +++- .../TopViewControllerProviderTests.swift | 40 +++++++++++++++++++ .../Entry/Sources/Login/LoginFeature.swift | 27 +++++++++++-- .../Entry/Sources/Login/LoginView.swift | 10 ++++- .../Entry/Tests/Login/LoginFeatureTests.swift | 6 ++- .../Tests/Support/EntryTestSupport.swift | 5 +++ .../UIKit/WindowSceneIdentifierReader.swift | 32 +++++++++++++++ .../Sources/Settings/AccountFeature.swift | 27 +++++++++++-- .../Sources/Settings/AccountView.swift | 13 +++++- .../Tests/Settings/AccountFeatureTests.swift | 14 ++++++- 12 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 Application/Core/Sources/Authentication/AuthPresentationContext.swift create mode 100644 Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift diff --git a/Application/Core/Sources/Authentication/AuthPresentationContext.swift b/Application/Core/Sources/Authentication/AuthPresentationContext.swift new file mode 100644 index 00000000..37c6b4cc --- /dev/null +++ b/Application/Core/Sources/Authentication/AuthPresentationContext.swift @@ -0,0 +1,17 @@ +// +// AuthPresentationContext.swift +// Core +// +// Created by opfic on 7/27/26. +// + +public struct AuthPresentationContext: Equatable, Sendable { + public let identifier: String + + // 인증 호출 경로에서 같은 key로 접근하되 실제 값은 각 Task에 격리하기 위한 정적 변수 + @TaskLocal public static var current: Self? + + public init(identifier: String) { + self.identifier = identifier + } +} diff --git a/Application/Infra/Sources/Common/TopViewControllerProvider.swift b/Application/Infra/Sources/Common/TopViewControllerProvider.swift index 63ffe3ef..21f0d820 100644 --- a/Application/Infra/Sources/Common/TopViewControllerProvider.swift +++ b/Application/Infra/Sources/Common/TopViewControllerProvider.swift @@ -8,14 +8,40 @@ import UIKit enum TopViewControllerProvider { + struct Candidate { + let identifier: String + let rootViewController: UIViewController? + } + @MainActor - static func topViewController() -> UIViewController? { - let rootViewController = UIApplication.shared.connectedScenes + static func topViewController(identifier: String) -> UIViewController? { + let candidates = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first(where: \.isKeyWindow)? - .rootViewController + .map { + Candidate( + identifier: $0.session.persistentIdentifier, + rootViewController: $0.windows + .first(where: \.isKeyWindow)? + .rootViewController + ) + } + return topViewController( + identifier: identifier, + candidates: candidates + ) + } + + @MainActor + static func topViewController( + identifier: String, + candidates: [Candidate] + ) -> UIViewController? { + let rootViewController = candidates + .first { + $0.identifier == identifier + }? + .rootViewController return topViewController(from: rootViewController) } diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index fe8edea5..9785c8a8 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -133,7 +133,10 @@ private extension GoogleAuthenticationServiceImpl { static func requestGoogleServerAuthCode( signOutPreviousSession: Bool = false ) async throws -> String { - guard let topViewController = TopViewControllerProvider.topViewController() else { + guard let identifier = AuthPresentationContext.current?.identifier, + let controller = TopViewControllerProvider.topViewController( + identifier: identifier + ) else { throw UIError.notFoundTopViewController } @@ -142,7 +145,7 @@ private extension GoogleAuthenticationServiceImpl { GIDSignIn.sharedInstance.signOut() } - let signIn = try await GIDSignIn.sharedInstance.signIn(withPresenting: topViewController) + let signIn = try await GIDSignIn.sharedInstance.signIn(withPresenting: controller) guard let serverAuthCode = signIn.serverAuthCode else { throw URLError(.badServerResponse) diff --git a/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift b/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift index 3857f781..78aed71c 100644 --- a/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift +++ b/Application/Infra/Tests/Common/TopViewControllerProviderTests.swift @@ -47,4 +47,44 @@ struct TopViewControllerProviderTests { #expect(result === selectedViewController) } + + @Test("요청 scene 식별자와 일치하는 화면을 반환한다") + func 요청_scene_식별자와_일치하는_화면을_반환한다() { + let otherViewController = UIViewController() + let requestedViewController = UIViewController() + let candidates = [ + TopViewControllerProvider.Candidate( + identifier: "other-scene", + rootViewController: otherViewController + ), + TopViewControllerProvider.Candidate( + identifier: "requested-scene", + rootViewController: requestedViewController + ) + ] + + let result = TopViewControllerProvider.topViewController( + identifier: "requested-scene", + candidates: candidates + ) + + #expect(result === requestedViewController) + } + + @Test("요청 scene 식별자와 일치하는 화면이 없으면 nil을 반환한다") + func 요청_scene_식별자와_일치하는_화면이_없으면_nil을_반환한다() { + let candidates = [ + TopViewControllerProvider.Candidate( + identifier: "other-scene", + rootViewController: UIViewController() + ) + ] + + let result = TopViewControllerProvider.topViewController( + identifier: "requested-scene", + candidates: candidates + ) + + #expect(result == nil) + } } diff --git a/Application/Presentation/Entry/Sources/Login/LoginFeature.swift b/Application/Presentation/Entry/Sources/Login/LoginFeature.swift index f06e764d..373876e9 100644 --- a/Application/Presentation/Entry/Sources/Login/LoginFeature.swift +++ b/Application/Presentation/Entry/Sources/Login/LoginFeature.swift @@ -6,6 +6,7 @@ // import Foundation +import Core import Domain import PresentationShared @@ -16,6 +17,7 @@ struct LoginFeature { @Presents var alert: AlertState? var activeSignInProvider: AuthProvider? var loading = LoadingFeature.State() + var presentationContext: AuthPresentationContext? var isLoading: Bool { loading.isLoading @@ -25,6 +27,7 @@ struct LoginFeature { enum Action { case alert(PresentationAction) case tapSignInButton(AuthProvider) + case setPresentationContext(AuthPresentationContext?) case signInFailed(AlertType) case loading(LoadingFeature.Action) } @@ -45,9 +48,18 @@ struct LoginFeature { case .alert: break case .tapSignInButton(let provider): - guard !state.isLoading else { return .none } + guard !state.isLoading, + let context = state.presentationContext + else { + return .none + } state.activeSignInProvider = provider - return signInEffect(provider) + return signInEffect( + provider, + context: context + ) + case .setPresentationContext(let context): + state.presentationContext = context case .signInFailed(let alertType): state.alert = Self.alertState(for: alertType) case .loading: @@ -79,11 +91,18 @@ private enum SignInUseCaseKey: DependencyKey { } private extension LoginFeature { - func signInEffect(_ provider: AuthProvider) -> Effect { + func signInEffect( + _ provider: AuthProvider, + context: AuthPresentationContext + ) -> Effect { .run { [signInUseCase] send in await send(.loading(.begin(target: .default, mode: .immediate))) do { - let signedIn = try await signInUseCase.execute(provider) + let signedIn = try await AuthPresentationContext + .$current + .withValue(context) { + try await signInUseCase.execute(provider) + } // 유스케이스 완료가 화면 전환 완료를 의미하지 않으므로 LoginView가 교체될 때까지 로딩을 유지한다. guard !signedIn else { return } await send(.loading(.end(target: .default, mode: .immediate))) diff --git a/Application/Presentation/Entry/Sources/Login/LoginView.swift b/Application/Presentation/Entry/Sources/Login/LoginView.swift index 781a2f0c..41db45f9 100644 --- a/Application/Presentation/Entry/Sources/Login/LoginView.swift +++ b/Application/Presentation/Entry/Sources/Login/LoginView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Core import PresentationShared import Domain @@ -59,6 +60,13 @@ struct LoginView: View { .padding(.vertical) } .alert($store.scope(state: \.alert, action: \.alert)) + .background { + WindowSceneIdentifierReader { + store.send(.setPresentationContext( + $0.map(AuthPresentationContext.init(identifier:)) + )) + } + } } private func signInButton( @@ -73,7 +81,7 @@ struct LoginView: View { ) { store.send(.tapSignInButton(provider)) } - .disabled(store.isLoading) + .disabled(store.isLoading || store.presentationContext == nil) .opacity(store.isLoading ? 0.5 : 1) } } diff --git a/Application/Presentation/Entry/Tests/Login/LoginFeatureTests.swift b/Application/Presentation/Entry/Tests/Login/LoginFeatureTests.swift index 0159b467..224937a2 100644 --- a/Application/Presentation/Entry/Tests/Login/LoginFeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Login/LoginFeatureTests.swift @@ -6,6 +6,7 @@ // import Testing +import Core import PresentationShared import Foundation import Domain @@ -25,6 +26,7 @@ struct LoginFeatureTests { } #expect(spy.calledProviders == [.github]) + #expect(spy.calledPresentationContextIdentifiers == ["scene-id"]) } @Test("로그인 성공 후에도 메인 화면 전환 전까지 로딩 상태를 유지한다") @@ -173,8 +175,10 @@ private struct LoginTestDriver { } init(useCase: SignInUseCase) { + var state = LoginFeature.State() + state.presentationContext = AuthPresentationContext(identifier: "scene-id") feature = ComposableArchitecture.Store( - initialState: LoginFeature.State() + initialState: state ) { LoginFeature() } withDependencies: { diff --git a/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift b/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift index 950f9757..4b1ac4a3 100644 --- a/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift +++ b/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift @@ -5,6 +5,7 @@ // Created by opfic on 7/6/26. // +import Core import Domain @MainActor @@ -26,12 +27,16 @@ final class SignInUseCaseSpy: SignInUseCase { var signedIn = true var shouldSuspend = false private(set) var calledProviders = [AuthProvider]() + private(set) var calledPresentationContextIdentifiers = [String]() private(set) var successfulProviders = [AuthProvider]() private var continuation: CheckedContinuation? private var shouldResume = false func execute(_ provider: AuthProvider) async throws -> Bool { calledProviders.append(provider) + calledPresentationContextIdentifiers.append( + AuthPresentationContext.current?.identifier ?? "" + ) if shouldSuspend { await withCheckedContinuation { continuation in diff --git a/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift b/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift new file mode 100644 index 00000000..68493079 --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift @@ -0,0 +1,32 @@ +// +// WindowSceneIdentifierReader.swift +// PresentationShared +// +// Created by opfic on 7/27/26. +// + +import SwiftUI + +public struct WindowSceneIdentifierReader: UIViewRepresentable { + private let onResolve: (String?) -> Void + + public init(onResolve: @escaping (String?) -> Void) { + self.onResolve = onResolve + } + + public func makeUIView(context: Context) -> UIView { + let view = UIView() + resolve(from: view) + return view + } + + public func updateUIView(_ uiView: UIView, context: Context) { + resolve(from: uiView) + } + + private func resolve(from view: UIView) { + DispatchQueue.main.async { + onResolve(view.window?.windowScene?.session.persistentIdentifier) + } + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift index b21eb811..0eae963e 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift @@ -5,6 +5,7 @@ // Created by opfic on 6/11/26. // +import Core import Domain import Foundation import PresentationShared @@ -19,6 +20,7 @@ struct AccountFeature { var disconnectedProviders: [AuthProvider] = [] var activeLoadingProvider: AuthProvider? var loading = LoadingFeature.State() + var presentationContext: AuthPresentationContext? var isLoading: Bool { loading.isLoading @@ -29,6 +31,7 @@ struct AccountFeature { case alert(PresentationAction) case onAppear case linkWithProvider(AuthProvider) + case setPresentationContext(AuthPresentationContext?) case unlinkFromProvider(AuthProvider) case setAlert(AlertType) case setProviders(currentProvider: AuthProvider?, allProviders: [AuthProvider]) @@ -58,9 +61,18 @@ struct AccountFeature { case .onAppear: return fetchProvidersEffect() case .linkWithProvider(let provider): - guard !state.isLoading else { return .none } + guard !state.isLoading, + let context = state.presentationContext + else { + return .none + } state.activeLoadingProvider = provider - return linkProviderEffect(provider) + return linkProviderEffect( + provider, + context: context + ) + case .setPresentationContext(let context): + state.presentationContext = context case .unlinkFromProvider(let provider): guard !state.isLoading else { return .none } state.activeLoadingProvider = provider @@ -147,11 +159,18 @@ private extension AccountFeature { } } - func linkProviderEffect(_ provider: AuthProvider) -> Effect { + func linkProviderEffect( + _ provider: AuthProvider, + context: AuthPresentationContext + ) -> Effect { .run { [fetchProvidersUseCase, linkProviderUseCase] send in await send(.loading(.begin(target: .default, mode: .delayed))) do { - let linked = try await linkProviderUseCase.execute(provider) + let linked = try await AuthPresentationContext + .$current + .withValue(context) { + try await linkProviderUseCase.execute(provider) + } guard linked else { await send(.loading(.end(target: .default, mode: .delayed))) return diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift b/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift index 96ae101a..961e6ef4 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Core import PresentationShared import Domain @@ -47,7 +48,10 @@ struct AccountView: View { .clipShape(.capsule) } .buttonStyle(.plain) - .disabled(store.isLoading) + .disabled( + store.isLoading || + (!isConnected && store.presentationContext == nil) + ) .opacity(showProgressView ? 0 : 1) .overlay { if showProgressView { @@ -63,6 +67,13 @@ struct AccountView: View { .navigationTitle(String(localized: "nav_account")) .onAppear { store.send(.onAppear) } .alert($store.scope(state: \.alert, action: \.alert)) + .background { + WindowSceneIdentifierReader { + store.send(.setPresentationContext( + $0.map(AuthPresentationContext.init(identifier:)) + )) + } + } } private func formattedProviderName(_ provider: AuthProvider) -> String { diff --git a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift index bdc2c49b..a258fe4a 100644 --- a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift +++ b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift @@ -8,6 +8,7 @@ // swiftlint:disable file_length import Testing +import Core import Foundation import Domain import PresentationShared @@ -55,6 +56,7 @@ struct AccountFeatureTests { #expect(driver.currentProvider == .google) #expect(driver.connectedProviders == [.github]) #expect(driver.disconnectedProviders == [.apple]) + #expect(linkSpy.presentationContextIdentifiers == ["scene-id"]) } @Test("연동 해제에 성공하면 선택한 제공자를 해제하고 제공자 목록을 다시 가져온다") @@ -90,7 +92,9 @@ struct AccountFeatureTests { let linkSpy = LinkAuthProviderUseCaseSpy() linkSpy.shouldSuspend = true let target = LoadingFeature.Target.default - let store = TestStore(initialState: AccountFeature.State()) { + var state = AccountFeature.State() + state.presentationContext = AuthPresentationContext(identifier: "scene-id") + let store = TestStore(initialState: state) { AccountFeature() } withDependencies: { $0.fetchAuthProvidersUseCase = fetchSpy @@ -282,7 +286,9 @@ private struct AccountTestDriver { linkUseCase: LinkAuthProviderUseCase = LinkAuthProviderUseCaseSpy(), unlinkUseCase: UnlinkAuthProviderUseCase = UnlinkAuthProviderUseCaseSpy() ) { - feature = Store(initialState: AccountFeature.State()) { + var state = AccountFeature.State() + state.presentationContext = AuthPresentationContext(identifier: "scene-id") + feature = Store(initialState: state) { AccountFeature() } withDependencies: { $0.fetchAuthProvidersUseCase = fetchUseCase @@ -345,11 +351,15 @@ private final class LinkAuthProviderUseCaseSpy: LinkAuthProviderUseCase { var linked = true var shouldSuspend = false private(set) var providers = [AuthProvider]() + private(set) var presentationContextIdentifiers = [String]() private var continuation: CheckedContinuation? private var shouldResume = false func execute(_ provider: AuthProvider) async throws -> Bool { providers.append(provider) + presentationContextIdentifiers.append( + AuthPresentationContext.current?.identifier ?? "" + ) if shouldSuspend { await withCheckedContinuation { continuation in From 96ece7c6db9aa0fdfd583ff9ed91e591a0209f86 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 27 Jul 2026 14:01:15 +0900 Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B3=B5=EA=B8=89=EC=9E=90=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Protocol/AuthenticationService.swift | 2 +- .../AuthenticationRepositoryImpl.swift | 25 ++++------ .../AuthenticationRepositoryImplTests.swift | 50 ++++++++++++++++--- .../Sources/Service/AuthServiceImpl.swift | 5 ++ .../AppleAuthenticationServiceImpl.swift | 26 +--------- .../GithubAuthenticationServiceImpl.swift | 26 +--------- .../GoogleAuthenticationServiceImpl.swift | 27 +--------- 7 files changed, 63 insertions(+), 98 deletions(-) diff --git a/Application/Data/Sources/Protocol/AuthenticationService.swift b/Application/Data/Sources/Protocol/AuthenticationService.swift index 1d2dc1ff..81f03030 100644 --- a/Application/Data/Sources/Protocol/AuthenticationService.swift +++ b/Application/Data/Sources/Protocol/AuthenticationService.swift @@ -9,7 +9,7 @@ import Foundation public protocol AuthenticationService { func signIn() async throws -> AuthDataResponse? - func signOut(_ uid: String) async throws + func signOut() func deleteAuth(_ uid: String) async throws func link(uid: String) async throws -> Bool func unlink(_ uid: String) async throws diff --git a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift index 7d405e60..92bf4ab1 100644 --- a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift @@ -64,29 +64,22 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { } func signOut() async throws { - guard let uid = authService.uid, - let providerID = try await authService.getProviderID(), - let provider = AuthProvider(rawValue: providerID) - else { + let providers = authService.providerIDs.compactMap { AuthProvider(rawValue: $0) } + + do { try await authService.clearCurrentSession() - widgetSnapshotUpdater.clear() - return + } catch { + throw error.toDomain() } - do { + for provider in providers { switch provider { case .apple: - try await appleAuthService.signOut(uid) + appleAuthService.signOut() case .github: - try await githubAuthService.signOut(uid) + githubAuthService.signOut() case .google: - try await googleAuthService.signOut(uid) - } - } catch { - if case AuthError.notAuthenticated = error.toDomain() { - try await authService.clearCurrentSession() - } else { - throw error.toDomain() + googleAuthService.signOut() } } diff --git a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift index 6a5a0b10..dad5919d 100644 --- a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift @@ -44,17 +44,46 @@ struct AuthenticationRepositoryImplTests { ]) } - @Test("Apple 로그아웃은 provider 로그아웃 뒤 위젯 데이터를 정리한다") - func Apple_로그아웃은_provider_로그아웃_뒤_위젯_데이터를_정리한다() async throws { + @Test("로그아웃은 공통 세션 정리 후 연결된 모든 provider 세션과 위젯 데이터를 정리한다") + func 로그아웃은_공통_세션_정리_후_연결된_모든_provider_세션과_위젯_데이터를_정리한다() async throws { let fixture = makeAuthenticationRepositoryFixture( uid: "user-id", - providerID: "apple.com" + providerIDs: ["apple.com", "github.com", "google.com"] ) try await fixture.repository.signOut() #expect(fixture.events.values() == [ + "auth.clearCurrentSession", "apple.signOut", + "github.signOut", + "google.signOut", + "widget.clear" + ]) + } + + @Test("공통 세션 정리 실패는 provider 세션과 위젯 데이터를 정리하지 않고 오류를 전달한다") + func 공통_세션_정리_실패는_provider_세션과_위젯_데이터를_정리하지_않고_오류를_전달한다() async { + let fixture = makeAuthenticationRepositoryFixture( + uid: "user-id", + providerIDs: ["apple.com", "github.com", "google.com"], + clearCurrentSessionError: AuthenticationRepositoryTestError.clearCurrentSession + ) + + await #expect(throws: AuthenticationRepositoryTestError.clearCurrentSession) { + try await fixture.repository.signOut() + } + #expect(fixture.events.values() == ["auth.clearCurrentSession"]) + } + + @Test("provider 목록이 없어도 공통 세션과 위젯 데이터를 정리한다") + func provider_목록이_없어도_공통_세션과_위젯_데이터를_정리한다() async throws { + let fixture = makeAuthenticationRepositoryFixture(uid: "user-id") + + try await fixture.repository.signOut() + + #expect(fixture.events.values() == [ + "auth.clearCurrentSession", "widget.clear" ]) } @@ -98,7 +127,8 @@ struct AuthenticationRepositoryImplTests { providerID: String? = nil, providerIDs: [String] = [], signInResult: Result = .success(nil), - deleteCurrentUserError: Error? = nil + deleteCurrentUserError: Error? = nil, + clearCurrentSessionError: Error? = nil ) -> AuthenticationRepositoryFixture { let events = AuthenticationRepositoryEventRecorder() let authService = AuthenticationRepositoryAuthServiceSpy( @@ -106,6 +136,7 @@ struct AuthenticationRepositoryImplTests { providerID: providerID, providerIDs: providerIDs, deleteCurrentUserError: deleteCurrentUserError, + clearCurrentSessionError: clearCurrentSessionError, events: events ) let appleAuthService = AuthenticationServiceSpy( @@ -149,6 +180,7 @@ private struct AuthenticationRepositoryFixture { } private enum AuthenticationRepositoryTestError: Error, Equatable { + case clearCurrentSession case requiresRecentLogin } @@ -171,6 +203,7 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { private let subject: CurrentValueSubject private let providerID: String? private let deleteCurrentUserError: Error? + private let clearCurrentSessionError: Error? private let events: AuthenticationRepositoryEventRecorder var uid: String? @@ -183,6 +216,7 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { providerIDs: [String], providerCount: Int? = nil, deleteCurrentUserError: Error? = nil, + clearCurrentSessionError: Error? = nil, events: AuthenticationRepositoryEventRecorder ) { self.uid = uid @@ -190,6 +224,7 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { self.providerIDs = providerIDs self.providerCount = providerCount ?? providerIDs.count self.deleteCurrentUserError = deleteCurrentUserError + self.clearCurrentSessionError = clearCurrentSessionError self.events = events self.subject = CurrentValueSubject(uid != nil) } @@ -223,10 +258,13 @@ final class AuthenticationRepositoryAuthServiceSpy: AuthService { func clearCurrentSession() async throws { events.record("auth.clearCurrentSession") + if let clearCurrentSessionError { + throw clearCurrentSessionError + } } } -actor AuthenticationServiceSpy: AuthenticationService { +final class AuthenticationServiceSpy: AuthenticationService { private let provider: String private let signInResult: Result private let linkResult: Result @@ -252,7 +290,7 @@ actor AuthenticationServiceSpy: AuthenticationService { return try signInResult.get() } - func signOut(_ uid: String) async throws { + func signOut() { events.record("\(provider).signOut") } diff --git a/Application/Infra/Sources/Service/AuthServiceImpl.swift b/Application/Infra/Sources/Service/AuthServiceImpl.swift index b04a6e32..89d386f4 100644 --- a/Application/Infra/Sources/Service/AuthServiceImpl.swift +++ b/Application/Infra/Sources/Service/AuthServiceImpl.swift @@ -119,6 +119,11 @@ final class AuthServiceImpl: AuthService { func clearCurrentSession() async throws { logger.info("Clearing current auth session") + if let uid { + let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) + try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) + } + do { if messaging.fcmToken != nil { try await messaging.deleteToken() diff --git a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 43bcf746..9ce2ec9d 100644 --- a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -7,8 +7,6 @@ import AuthenticationServices import FirebaseAuth -import FirebaseFirestore -import FirebaseMessaging import Foundation import Core import Data @@ -19,7 +17,6 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { enum Code: Int { case signIn = 1 - case signOut case deleteAuth case link case unlink @@ -28,8 +25,6 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { private var appleSignInDelegate: AppleSignInDelegate? private var appleSignInContinuation: CheckedContinuation? - private let store = FirebaseConfiguration.firestore - private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let logger = Logger(category: "AppleAuthService") @@ -65,26 +60,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } } - func signOut(_ uid: String) async throws { - do { - let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) - try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) - - if messaging.fcmToken != nil { - do { - try await messaging.deleteToken() - } catch { - logger.error("Failed to delete FCM token while signing out with Apple", error: error) - } - } - - try Auth.auth().signOut() - } catch { - logger.error("Failed to sign out with Apple", error: error) - record(error, code: .signOut) - throw error - } - } + func signOut() { } func deleteAuth(_ uid: String) async throws { do { diff --git a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index a616dee6..9f3658a5 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -6,8 +6,6 @@ // import FirebaseAuth -import FirebaseFirestore -import FirebaseMessaging import Core import Data @@ -17,15 +15,12 @@ final class GithubAuthenticationServiceImpl: AuthenticationService { enum Code: Int { case signIn = 1 - case signOut case deleteAuth case link case unlink } } - private let store = FirebaseConfiguration.firestore - private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let logger = Logger(category: "GithubAuthService") @@ -58,26 +53,7 @@ final class GithubAuthenticationServiceImpl: AuthenticationService { } } - func signOut(_ uid: String) async throws { - do { - let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) - try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) - - if messaging.fcmToken != nil { - do { - try await messaging.deleteToken() - } catch { - logger.error("Failed to delete FCM token while signing out with GitHub", error: error) - } - } - - try Auth.auth().signOut() - } catch { - logger.error("Failed to sign out with GitHub", error: error) - record(error, code: .signOut) - throw error - } - } + func signOut() { } func deleteAuth(_ uid: String) async throws { do { diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 9785c8a8..4d5e8a3d 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -6,8 +6,6 @@ // import FirebaseAuth -import FirebaseFirestore -import FirebaseMessaging import Foundation import GoogleSignIn import Core @@ -19,15 +17,12 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { enum Code: Int { case signIn = 1 - case signOut case deleteAuth case link case unlink } } - private let store = FirebaseConfiguration.firestore - private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let logger = Logger(category: "GoogleAuthService") @@ -59,26 +54,8 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { } } - func signOut(_ uid: String) async throws { - do { - let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) - try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) - - if messaging.fcmToken != nil { - do { - try await messaging.deleteToken() - } catch { - logger.error("Failed to delete FCM token while signing out with Google", error: error) - } - } - - try Auth.auth().signOut() - GIDSignIn.sharedInstance.signOut() - } catch { - logger.error("Failed to sign out with Google", error: error) - record(error, code: .signOut) - throw error - } + func signOut() { + GIDSignIn.sharedInstance.signOut() } func deleteAuth(_ uid: String) async throws { From 458e807e7e1dd8453f42a0b9a53b6ed0497499bb Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 27 Jul 2026 22:32:44 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20scene=20identifier=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=84=EB=8B=AC=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UIKit/WindowSceneIdentifierReader.swift | 24 +++++++++++++++---- .../WindowSceneIdentifierReaderTests.swift | 22 +++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 Application/Presentation/PresentationShared/Tests/UIKit/WindowSceneIdentifierReaderTests.swift diff --git a/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift b/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift index 68493079..74f0b0aa 100644 --- a/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift +++ b/Application/Presentation/PresentationShared/Sources/UIKit/WindowSceneIdentifierReader.swift @@ -10,23 +10,39 @@ import SwiftUI public struct WindowSceneIdentifierReader: UIViewRepresentable { private let onResolve: (String?) -> Void + public final class Coordinator { + private var identifier: String? + + func update(identifier: String?) -> Bool { + guard self.identifier != identifier else { return false } + self.identifier = identifier + return true + } + } + public init(onResolve: @escaping (String?) -> Void) { self.onResolve = onResolve } + public func makeCoordinator() -> Coordinator { + Coordinator() + } + public func makeUIView(context: Context) -> UIView { let view = UIView() - resolve(from: view) + resolve(from: view, coordinator: context.coordinator) return view } public func updateUIView(_ uiView: UIView, context: Context) { - resolve(from: uiView) + resolve(from: uiView, coordinator: context.coordinator) } - private func resolve(from view: UIView) { + private func resolve(from view: UIView, coordinator: Coordinator) { DispatchQueue.main.async { - onResolve(view.window?.windowScene?.session.persistentIdentifier) + let identifier = view.window?.windowScene?.session.persistentIdentifier + guard coordinator.update(identifier: identifier) else { return } + onResolve(identifier) } } } diff --git a/Application/Presentation/PresentationShared/Tests/UIKit/WindowSceneIdentifierReaderTests.swift b/Application/Presentation/PresentationShared/Tests/UIKit/WindowSceneIdentifierReaderTests.swift new file mode 100644 index 00000000..80fb972a --- /dev/null +++ b/Application/Presentation/PresentationShared/Tests/UIKit/WindowSceneIdentifierReaderTests.swift @@ -0,0 +1,22 @@ +// +// WindowSceneIdentifierReaderTests.swift +// PresentationSharedTests +// +// Created by opfic on 7/27/26. +// + +import Testing +@testable import PresentationShared + +struct WindowSceneIdentifierReaderTests { + @Test("Coordinator는 scene identifier가 변경될 때만 갱신한다") + func Coordinator는_scene_identifier가_변경될_때만_갱신한다() { + let coordinator = WindowSceneIdentifierReader.Coordinator() + + #expect(coordinator.update(identifier: nil) == false) + #expect(coordinator.update(identifier: "scene-a")) + #expect(coordinator.update(identifier: "scene-a") == false) + #expect(coordinator.update(identifier: "scene-b")) + #expect(coordinator.update(identifier: nil)) + } +} From 885fed0e32c5858e1a5fd74d30acc90462388865 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 27 Jul 2026 22:50:36 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20=EA=B3=B5=EA=B8=89=EC=9E=90=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=84=B8=EC=85=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Protocol/AuthenticationService.swift | 2 +- .../AuthenticationRepositoryImpl.swift | 26 +++++++++++-------- .../AuthenticationRepositoryImplTests.swift | 21 +++++++++------ .../AppleAuthenticationServiceImpl.swift | 2 +- .../GithubAuthenticationServiceImpl.swift | 2 +- .../GoogleAuthenticationServiceImpl.swift | 2 +- 6 files changed, 32 insertions(+), 23 deletions(-) diff --git a/Application/Data/Sources/Protocol/AuthenticationService.swift b/Application/Data/Sources/Protocol/AuthenticationService.swift index 81f03030..71818cc3 100644 --- a/Application/Data/Sources/Protocol/AuthenticationService.swift +++ b/Application/Data/Sources/Protocol/AuthenticationService.swift @@ -9,7 +9,7 @@ import Foundation public protocol AuthenticationService { func signIn() async throws -> AuthDataResponse? - func signOut() + func clearLocalSession() func deleteAuth(_ uid: String) async throws func link(uid: String) async throws -> Bool func unlink(_ uid: String) async throws diff --git a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift index 92bf4ab1..f80bd7ce 100644 --- a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift @@ -72,17 +72,7 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { throw error.toDomain() } - for provider in providers { - switch provider { - case .apple: - appleAuthService.signOut() - case .github: - githubAuthService.signOut() - case .google: - googleAuthService.signOut() - } - } - + clearProviderSessions(providers) widgetSnapshotUpdater.clear() } @@ -107,6 +97,7 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { do { try await authService.deleteCurrentUser() try await authService.clearCurrentSession() + clearProviderSessions(providers) widgetSnapshotUpdater.clear() } catch { throw error.toDomain() @@ -115,6 +106,19 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { } private extension AuthenticationRepositoryImpl { + func clearProviderSessions(_ providers: [AuthProvider]) { + for provider in providers { + switch provider { + case .apple: + appleAuthService.clearLocalSession() + case .github: + githubAuthService.clearLocalSession() + case .google: + googleAuthService.clearLocalSession() + } + } + } + func mapSignInError(_ error: Error) -> Error { if let emailError = error as? EmailError, emailError == .notFound { diff --git a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift index dad5919d..02eaecf8 100644 --- a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift @@ -55,9 +55,9 @@ struct AuthenticationRepositoryImplTests { #expect(fixture.events.values() == [ "auth.clearCurrentSession", - "apple.signOut", - "github.signOut", - "google.signOut", + "apple.clearLocalSession", + "github.clearLocalSession", + "google.clearLocalSession", "widget.clear" ]) } @@ -88,19 +88,24 @@ struct AuthenticationRepositoryImplTests { ]) } - @Test("Apple 회원탈퇴는 grant 정리 후 사용자와 세션과 위젯 데이터를 정리한다") - func Apple_회원탈퇴는_grant_정리_후_사용자와_세션과_위젯_데이터를_정리한다() async throws { + @Test("회원탈퇴는 provider grant 정리 후 사용자와 로컬 세션과 위젯 데이터를 정리한다") + func 회원탈퇴는_provider_grant_정리_후_사용자와_로컬_세션과_위젯_데이터를_정리한다() async throws { let fixture = makeAuthenticationRepositoryFixture( uid: "user-id", - providerIDs: ["apple.com"] + providerIDs: ["apple.com", "github.com", "google.com"] ) try await fixture.repository.delete() #expect(fixture.events.values() == [ "apple.deleteAuth", + "github.deleteAuth", + "google.deleteAuth", "auth.deleteCurrentUser", "auth.clearCurrentSession", + "apple.clearLocalSession", + "github.clearLocalSession", + "google.clearLocalSession", "widget.clear" ]) } @@ -290,8 +295,8 @@ final class AuthenticationServiceSpy: AuthenticationService { return try signInResult.get() } - func signOut() { - events.record("\(provider).signOut") + func clearLocalSession() { + events.record("\(provider).clearLocalSession") } func deleteAuth(_ uid: String) async throws { diff --git a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 9ce2ec9d..eaad706a 100644 --- a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -60,7 +60,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } } - func signOut() { } + func clearLocalSession() { } func deleteAuth(_ uid: String) async throws { do { diff --git a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index 9f3658a5..84ced79c 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -53,7 +53,7 @@ final class GithubAuthenticationServiceImpl: AuthenticationService { } } - func signOut() { } + func clearLocalSession() { } func deleteAuth(_ uid: String) async throws { do { diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 4d5e8a3d..bf848497 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -54,7 +54,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { } } - func signOut() { + func clearLocalSession() { GIDSignIn.sharedInstance.signOut() }