From 793b7b66069553ff0eb7d0eeb00f0446f556293e Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 04:17:38 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20Firebase=20SDK=20=EB=B0=8F=20FCM?= =?UTF-8?q?=20Core=20=EB=AA=A8=EB=93=88=20Tuist=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Core/FCM/Project.swift | 11 +++ Projects/Core/FCM/Sources/FCMManager.swift | 87 ++++++++++++++++++ .../Core/FCM/Sources/FCMTokenProviding.swift | 11 +++ Projects/Core/FCM/Sources/FCMTokenStore.swift | 34 +++++++ .../Core/FCM/Sources/Factory/FCMFactory.swift | 13 +++ Tuist/Package.resolved | 90 +++++++++++++++++++ Tuist/Package.swift | 1 + .../Configuration/DefaultSettings.swift | 33 ++++++- .../Core/Module.swift | 3 +- .../Dependencies/ExternalLibrary.swift | 22 ++++- .../Dependencies/ModuleDependencies.swift | 9 ++ .../Templates/Project+Framework.swift | 17 +++- 12 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 Projects/Core/FCM/Project.swift create mode 100644 Projects/Core/FCM/Sources/FCMManager.swift create mode 100644 Projects/Core/FCM/Sources/FCMTokenProviding.swift create mode 100644 Projects/Core/FCM/Sources/FCMTokenStore.swift create mode 100644 Projects/Core/FCM/Sources/Factory/FCMFactory.swift diff --git a/Projects/Core/FCM/Project.swift b/Projects/Core/FCM/Project.swift new file mode 100644 index 00000000..ffae61c8 --- /dev/null +++ b/Projects/Core/FCM/Project.swift @@ -0,0 +1,11 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.framework( + .fcm, + additionalBaseSettings: [ + "PRODUCT_NAME": "FCM", + "ENABLE_MODULE_VERIFIER": "NO", + "CLANG_ENABLE_MODULE_VERIFIER": "NO", + ] +) diff --git a/Projects/Core/FCM/Sources/FCMManager.swift b/Projects/Core/FCM/Sources/FCMManager.swift new file mode 100644 index 00000000..b7a7d362 --- /dev/null +++ b/Projects/Core/FCM/Sources/FCMManager.swift @@ -0,0 +1,87 @@ +import FirebaseCore +import FirebaseMessaging +import Logger + +public final class FCMManager: NSObject, FCMTokenProviding { + public static let shared = FCMManager() + + private let tokenStore = FCMTokenStore() + + public func currentToken() async -> String? { + await tokenStore.currentToken() + } + + // swiftformat:disable:next modifierOrder + private override init() { + super.init() + } + + // MARK: - Token + + public func fcmTokenUpdates() -> AsyncStream { + let tokenStore = tokenStore + + return AsyncStream { continuation in + let streamID = UUID() + + Task { + await tokenStore.registerStream( + id: streamID, + continuation: continuation + ) + } + + continuation.onTermination = { @Sendable _ in + Task { + await tokenStore.unregisterStream(id: streamID) + } + } + } + } + + // MARK: - Bootstrap + + public func configure() { + guard FirebaseApp.app() == nil else { return } + + FirebaseApp.configure() + Messaging.messaging().delegate = self + + Logger.shared.info("Firebase 초기화 완료", category: .general) + } + + public func setAPNSToken(_ deviceToken: Data) { + Messaging.messaging().apnsToken = deviceToken + + Logger.shared.info( + "APNs device token 등록 완료", + category: .general + ) + } + + private func deliverToken(_ token: String) { + Task { + let isNewToken = await tokenStore.updateToken(token) + guard isNewToken else { return } + + #if DEBUG + Logger.shared.debug("FCM 토큰: \(token)", category: .general) + #else + Logger.shared.info("FCM 토큰 갱신됨", category: .general) + #endif + } + } +} + +// MARK: - MessagingDelegate + +extension FCMManager: MessagingDelegate { + public func messaging( + _ messaging: Messaging, + didReceiveRegistrationToken fcmToken: String? + ) { + guard let fcmToken else { return } + + deliverToken(fcmToken) + } +} diff --git a/Projects/Core/FCM/Sources/FCMTokenProviding.swift b/Projects/Core/FCM/Sources/FCMTokenProviding.swift new file mode 100644 index 00000000..03675f18 --- /dev/null +++ b/Projects/Core/FCM/Sources/FCMTokenProviding.swift @@ -0,0 +1,11 @@ +import Foundation + +public protocol FCMTokenProviding: AnyObject { + func currentToken() async -> String? + + /// 토큰 갱신 스트림. 구독 시점에 이미 토큰이 있으면 즉시 한 번 방출한다. + func fcmTokenUpdates() -> AsyncStream + + func configure() + func setAPNSToken(_ deviceToken: Data) +} diff --git a/Projects/Core/FCM/Sources/FCMTokenStore.swift b/Projects/Core/FCM/Sources/FCMTokenStore.swift new file mode 100644 index 00000000..ae247e89 --- /dev/null +++ b/Projects/Core/FCM/Sources/FCMTokenStore.swift @@ -0,0 +1,34 @@ +import Foundation + +actor FCMTokenStore { + private var token: String? + private var streamContinuations: [UUID: AsyncStream.Continuation] = [:] + + func currentToken() -> String? { + token + } + + func registerStream( + id: UUID, + continuation: AsyncStream.Continuation + ) { + streamContinuations[id] = continuation + + if let token { + continuation.yield(token) + } + } + + func unregisterStream(id: UUID) { + streamContinuations.removeValue(forKey: id) + } + + func updateToken(_ newToken: String) -> Bool { + guard token != newToken else { return false } + + token = newToken + streamContinuations.values.forEach { $0.yield(newToken) } + + return true + } +} diff --git a/Projects/Core/FCM/Sources/Factory/FCMFactory.swift b/Projects/Core/FCM/Sources/Factory/FCMFactory.swift new file mode 100644 index 00000000..89fd7103 --- /dev/null +++ b/Projects/Core/FCM/Sources/Factory/FCMFactory.swift @@ -0,0 +1,13 @@ +public protocol FCMFactory: AnyObject { + func makeFCMTokenProvider() -> FCMTokenProviding +} + +public final class DefaultFCMFactory: FCMFactory { + public static let shared = DefaultFCMFactory() + + private init() {} + + public func makeFCMTokenProvider() -> FCMTokenProviding { + FCMManager.shared + } +} diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 8fc63ebb..1ecb84e7 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, { "identity" : "alamofire", "kind" : "remoteSourceControl", @@ -54,6 +63,42 @@ "version" : "2.0.0" } }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk.git", + "state" : { + "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5", + "version" : "11.15.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed", + "version" : "2.3.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "45ce435e9406d3c674dd249a042b932bee006f60", + "version" : "11.15.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, { "identity" : "googlesignin-ios", "kind" : "remoteSourceControl", @@ -72,6 +117,15 @@ "version" : "8.1.0" } }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, { "identity" : "gtm-session-fetcher", "kind" : "remoteSourceControl", @@ -90,6 +144,15 @@ "version" : "5.0.0" } }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, { "identity" : "kakao-ios-sdk", "kind" : "remoteSourceControl", @@ -108,6 +171,15 @@ "version" : "8.9.0" } }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, { "identity" : "mapbox-common-ios", "kind" : "remoteSourceControl", @@ -135,6 +207,15 @@ "version" : "11.23.1" } }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, { "identity" : "parchment", "kind" : "remoteSourceControl", @@ -189,6 +270,15 @@ "version" : "3.1.0" } }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "f6506eaa86ed2e01cb0ae14a75035b7fdbf0918f", + "version" : "1.38.0" + } + }, { "identity" : "turf-swift", "kind" : "remoteSourceControl", diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 0b2d76cd..ad2255e8 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -20,5 +20,6 @@ let package = Package( .package(url: "https://github.com/amplitude/Amplitude-Swift", from: "1.16.5"), .package(url: "https://github.com/googleads/swift-package-manager-google-mobile-ads.git", from: "12.14.0"), .package(url: "https://github.com/rechsteiner/Parchment", from: "4.0.0"), + .package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "11.14.0"), ] ) diff --git a/Tuist/ProjectDescriptionHelpers/Configuration/DefaultSettings.swift b/Tuist/ProjectDescriptionHelpers/Configuration/DefaultSettings.swift index 42cf6787..14d53903 100644 --- a/Tuist/ProjectDescriptionHelpers/Configuration/DefaultSettings.swift +++ b/Tuist/ProjectDescriptionHelpers/Configuration/DefaultSettings.swift @@ -36,6 +36,9 @@ public enum DefaultSettings { // Scripts "ENABLE_USER_SCRIPT_SANDBOXING": "NO", + + // static SPM + 멀티모듈 클린 빌드 시 explicit module 스캔 순서 이슈 방지 + "SWIFT_ENABLE_EXPLICIT_MODULES": "NO", ] // MARK: - Debug Settings @@ -47,7 +50,8 @@ public enum DefaultSettings { "GCC_OPTIMIZATION_LEVEL": "0", "DEBUG_INFORMATION_FORMAT": "dwarf", "ENABLE_TESTABILITY": "YES", - "OTHER_SWIFT_FLAGS": "-D DEBUG", + // Tuist SPM(static)이 주입하는 -Xcc -fmodule-map-file 플래그를 유지해야 함 + "OTHER_SWIFT_FLAGS": "$(inherited) -D DEBUG", ] // MARK: - Release Settings @@ -95,9 +99,32 @@ public enum DefaultSettings { ] } + /// 프레임워크 타겟 전용 — configuration마다 동일한 override를 병합한다. + public static func frameworkConfigurations( + merging additional: SettingsDictionary = [:] + ) -> [Configuration] { + let debug = base.merging(debugSettings).merging(additional) + let release = base.merging(releaseSettings).merging(additional) + + return [ + .debug( + name: Environment.debugConfigName, + settings: debug + ), + .release( + name: Environment.releaseConfigName, + settings: release + ), + ] + } + public static func targetConfigurations() -> [Configuration] { - let debugAppSettings = appSettings(for: .debug) - let releaseAppSettings = appSettings(for: .release) + let debugAppSettings = appSettings(for: .debug).merging([ + "CODE_SIGN_ENTITLEMENTS": .string("App-Debug.entitlements"), + ]) + let releaseAppSettings = appSettings(for: .release).merging([ + "CODE_SIGN_ENTITLEMENTS": .string("App-Release.entitlements"), + ]) return [ .debug( diff --git a/Tuist/ProjectDescriptionHelpers/Core/Module.swift b/Tuist/ProjectDescriptionHelpers/Core/Module.swift index 597cf43a..c083ca77 100644 --- a/Tuist/ProjectDescriptionHelpers/Core/Module.swift +++ b/Tuist/ProjectDescriptionHelpers/Core/Module.swift @@ -9,6 +9,7 @@ public enum Module: String, CaseIterable { case networking = "Networking" case logger = "Logger" case analytics = "Analytics" + case fcm = "FCM" case storage = "Storage" case ads = "Ads" @@ -23,7 +24,7 @@ public extension Module { .relativeToRoot("Projects/App") case .presentation, .domain, .data: .relativeToRoot("Projects/\(rawValue)") - case .networking, .logger, .analytics, .storage, .ads: + case .networking, .logger, .analytics, .fcm, .storage, .ads: .relativeToRoot("Projects/Core/\(rawValue)") case .designSystem, .utils: .relativeToRoot("Projects/Shared/\(rawValue)") diff --git a/Tuist/ProjectDescriptionHelpers/Dependencies/ExternalLibrary.swift b/Tuist/ProjectDescriptionHelpers/Dependencies/ExternalLibrary.swift index 5ccdf39a..5a5557aa 100644 --- a/Tuist/ProjectDescriptionHelpers/Dependencies/ExternalLibrary.swift +++ b/Tuist/ProjectDescriptionHelpers/Dependencies/ExternalLibrary.swift @@ -37,11 +37,28 @@ public enum ExternalLibrary: String, CaseIterable { // MARK: - Google Mobile Ads case googleMobileAds = "GoogleMobileAds" + + // MARK: - Firebase + + case firebaseCore = "FirebaseCore" + case firebaseMessaging = "FirebaseMessaging" } public extension ExternalLibrary { // MARK: - Product Type Configuration + private static let sharedGoogleProductTypes: [String: Product] = [ + "FBLPromises": .framework, + "GoogleUtilities-AppDelegateSwizzler": .framework, + "GoogleUtilities-Environment": .framework, + "GoogleUtilities-Logger": .framework, + "GoogleUtilities-Network": .framework, + "GoogleUtilities-NSData": .framework, + "GoogleUtilities-Reachability": .framework, + "GoogleUtilities-UserDefaults": .framework, + "third-party-IsAppEncrypted": .framework, + ] + var productType: Product { switch self { case .mapboxMaps: .framework @@ -53,11 +70,14 @@ public extension ExternalLibrary { // MARK: - Package Settings static var packageSettings: PackageSettings { - let productTypes = Dictionary( + let externalProductTypes = Dictionary( uniqueKeysWithValues: ExternalLibrary.allCases.map { ($0.rawValue, $0.productType) } ) + let productTypes = externalProductTypes.merging(sharedGoogleProductTypes) { _, new in + new + } return PackageSettings( productTypes: productTypes, diff --git a/Tuist/ProjectDescriptionHelpers/Dependencies/ModuleDependencies.swift b/Tuist/ProjectDescriptionHelpers/Dependencies/ModuleDependencies.swift index 21b3c7d8..64183d37 100644 --- a/Tuist/ProjectDescriptionHelpers/Dependencies/ModuleDependencies.swift +++ b/Tuist/ProjectDescriptionHelpers/Dependencies/ModuleDependencies.swift @@ -10,6 +10,7 @@ public enum ModuleDependencies { .module(.data), .module(.designSystem), .module(.analytics), + .module(.fcm), .module(.storage), .module(.networking), .module(.utils), @@ -63,6 +64,14 @@ public enum ModuleDependencies { .external(.amplitudeSwift), ] + case .fcm: + [ + .module(.logger), + + .external(.firebaseCore), + .external(.firebaseMessaging), + ] + case .storage: [ .module(.logger), diff --git a/Tuist/ProjectDescriptionHelpers/Templates/Project+Framework.swift b/Tuist/ProjectDescriptionHelpers/Templates/Project+Framework.swift index f21e288b..d260a2b0 100644 --- a/Tuist/ProjectDescriptionHelpers/Templates/Project+Framework.swift +++ b/Tuist/ProjectDescriptionHelpers/Templates/Project+Framework.swift @@ -5,12 +5,13 @@ public extension Project { _ module: Module, hasResources: Bool = false, hasTests: Bool = false, - additionalScripts: [TargetScript] = [] + additionalScripts: [TargetScript] = [], + additionalBaseSettings: SettingsDictionary = [:] ) -> Project { let dependencies = ModuleDependencies.dependencies(for: module) let resources: ResourceFileElements? = hasResources ? ["Resources/**"] : nil let scripts = BuildScripts.framework + additionalScripts - + let baseSettings = DefaultSettings.base.merging(additionalBaseSettings) var targets: [Target] = [ .target( name: module.rawValue, @@ -22,7 +23,15 @@ public extension Project { sources: ["Sources/**"], resources: resources, scripts: scripts, - dependencies: dependencies + dependencies: dependencies, + settings: additionalBaseSettings.isEmpty + ? nil + : .settings( + base: baseSettings, + configurations: DefaultSettings.frameworkConfigurations( + merging: additionalBaseSettings + ) + ) ), ] @@ -39,7 +48,7 @@ public extension Project { name: module.rawValue, organizationName: Environment.organizationName, settings: .settings( - base: DefaultSettings.base, + base: baseSettings, configurations: DefaultSettings.configurations() ), targets: targets From fa5e12e4bdf146112fff78a2a17c7fc7cc4a6cf0 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 04:17:42 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Push=20entitlements=20=EB=B0=8F=20G?= =?UTF-8?q?oogleService-Info=20=EB=B9=8C=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Config/Example.xcconfig | 4 +++ Projects/App/App-Debug.entitlements | 12 +++++++ ....entitlements => App-Release.entitlements} | 2 ++ .../Configuration/DefaultInfoPlist.swift | 5 +++ .../Scripts/BuildScripts.swift | 33 ++++++++++++++++++- .../Templates/Project+App.swift | 1 - 7 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 Projects/App/App-Debug.entitlements rename Projects/App/{App.entitlements => App-Release.entitlements} (82%) diff --git a/.gitignore b/.gitignore index 3f8e1159..3dacc8ae 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ Package.resolved # Config files Config/*.xcconfig !Config/Example.xcconfig +Config/GoogleService-Info*.plist # System files .DS_Store diff --git a/Config/Example.xcconfig b/Config/Example.xcconfig index 1024c3e4..bfdbeb8c 100644 --- a/Config/Example.xcconfig +++ b/Config/Example.xcconfig @@ -22,3 +22,7 @@ AMPLITUDE_API_KEY = // AdMob App ID ADMOB_APP_ID = ADMOB_BANNER_AD_UNIT_ID = + +// Firebase (GoogleService-Info.plist — Git 미추적, Config/에 로컬 배치) +// Debug: GoogleService-Info-Debug.plist (BUNDLE_ID: com.swyp.souzip.dev) +// Release: GoogleService-Info-Release.plist (BUNDLE_ID: com.swyp.souzip) diff --git a/Projects/App/App-Debug.entitlements b/Projects/App/App-Debug.entitlements new file mode 100644 index 00000000..539a3fdd --- /dev/null +++ b/Projects/App/App-Debug.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.developer.applesignin + + Default + + + diff --git a/Projects/App/App.entitlements b/Projects/App/App-Release.entitlements similarity index 82% rename from Projects/App/App.entitlements rename to Projects/App/App-Release.entitlements index e8bfba96..7e0fd360 100644 --- a/Projects/App/App.entitlements +++ b/Projects/App/App-Release.entitlements @@ -2,6 +2,8 @@ + aps-environment + production com.apple.developer.applesignin Default diff --git a/Tuist/ProjectDescriptionHelpers/Configuration/DefaultInfoPlist.swift b/Tuist/ProjectDescriptionHelpers/Configuration/DefaultInfoPlist.swift index 9567e757..772ca51e 100644 --- a/Tuist/ProjectDescriptionHelpers/Configuration/DefaultInfoPlist.swift +++ b/Tuist/ProjectDescriptionHelpers/Configuration/DefaultInfoPlist.swift @@ -45,6 +45,11 @@ public enum DefaultInfoPlist { ], ], + // Push Notifications + "UIBackgroundModes": [ + "remote-notification", + ], + // Export Compliance "ITSAppUsesNonExemptEncryption": false, diff --git a/Tuist/ProjectDescriptionHelpers/Scripts/BuildScripts.swift b/Tuist/ProjectDescriptionHelpers/Scripts/BuildScripts.swift index fdece7c8..9fe36f9b 100644 --- a/Tuist/ProjectDescriptionHelpers/Scripts/BuildScripts.swift +++ b/Tuist/ProjectDescriptionHelpers/Scripts/BuildScripts.swift @@ -33,10 +33,41 @@ public enum BuildScripts { basedOnDependencyAnalysis: false ) + // MARK: - GoogleService-Info.plist + + public static let copyGoogleServiceInfo: TargetScript = .pre( + script: """ + ROOT_DIR="${SRCROOT}" + while [ ! -f "${ROOT_DIR}/Config/Example.xcconfig" ] && [ "${ROOT_DIR}" != "/" ]; do + ROOT_DIR=$(dirname "${ROOT_DIR}") + done + + if [ "${CONFIGURATION}" = "Debug" ]; then + SOURCE_PLIST="${ROOT_DIR}/Config/GoogleService-Info-Debug.plist" + else + SOURCE_PLIST="${ROOT_DIR}/Config/GoogleService-Info-Release.plist" + fi + + DEST_DIR="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" + mkdir -p "${DEST_DIR}" + + if [ ! -f "${SOURCE_PLIST}" ]; then + echo "error: Firebase plist not found at ${SOURCE_PLIST}." + echo "Place GoogleService-Info-Debug.plist and GoogleService-Info-Release.plist in Config/. See Config/Example.xcconfig." + exit 1 + fi + + cp "${SOURCE_PLIST}" "${DEST_DIR}/GoogleService-Info.plist" + echo "Copied ${SOURCE_PLIST} -> ${DEST_DIR}/GoogleService-Info.plist" + """, + name: "Copy GoogleService-Info.plist", + basedOnDependencyAnalysis: false + ) + // MARK: - Default Collections public static var app: [TargetScript] { - [swiftFormat] + [copyGoogleServiceInfo, swiftFormat] } public static var framework: [TargetScript] { diff --git a/Tuist/ProjectDescriptionHelpers/Templates/Project+App.swift b/Tuist/ProjectDescriptionHelpers/Templates/Project+App.swift index dbeae346..fa866eda 100644 --- a/Tuist/ProjectDescriptionHelpers/Templates/Project+App.swift +++ b/Tuist/ProjectDescriptionHelpers/Templates/Project+App.swift @@ -25,7 +25,6 @@ public extension Project { infoPlist: infoPlist ?? DefaultInfoPlist.app, sources: ["Sources/**"], resources: ["Resources/**"], - entitlements: .file(path: .relativeToRoot("Projects/App/App.entitlements")), scripts: scripts, dependencies: dependencies, settings: .settings( From cc99b73f2c3eed91fec754b83677560d87412238 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 04:17:46 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20App=20FCM=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B6=80=ED=8A=B8=EC=8A=A4=ED=8A=B8=EB=9E=A9=20=EB=B0=8F=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=EA=B6=8C=ED=95=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/AppDelegate.swift | 40 ++++++++++++- Projects/App/Sources/Factory/AppFactory.swift | 8 ++- .../Push/PushNotificationRegistrar.swift | 58 +++++++++++++++++++ .../App/Sources/Push/PushTokenSyncer.swift | 20 +++++++ Projects/App/Sources/SceneDelegate.swift | 7 +++ 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 Projects/App/Sources/Push/PushNotificationRegistrar.swift create mode 100644 Projects/App/Sources/Push/PushTokenSyncer.swift diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index 97876b6a..557d5df8 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -1,12 +1,50 @@ +import FCM +import Logger import UIKit @main final class AppDelegate: UIResponder, UIApplicationDelegate { + private let fcmTokenProvider: FCMTokenProviding = DefaultFCMFactory.shared.makeFCMTokenProvider() + private let pushTokenSyncer: PushTokenSyncing = DeferredPushTokenSyncer() + private var pushTokenSyncTask: Task? + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - true + fcmTokenProvider.configure() + observeFCMTokenUpdates() + return true + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + fcmTokenProvider.setAPNSToken(deviceToken) + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + Logger.shared.error( + "APNs 등록 실패: \(error.localizedDescription)", + category: .general + ) + } + + private func observeFCMTokenUpdates() { + pushTokenSyncTask?.cancel() + + let tokenUpdates = fcmTokenProvider.fcmTokenUpdates() + let pushTokenSyncer = pushTokenSyncer + + pushTokenSyncTask = Task { + for await token in tokenUpdates { + await pushTokenSyncer.sync(fcmToken: token) + } + } } func application( diff --git a/Projects/App/Sources/Factory/AppFactory.swift b/Projects/App/Sources/Factory/AppFactory.swift index accaae57..bb516672 100644 --- a/Projects/App/Sources/Factory/AppFactory.swift +++ b/Projects/App/Sources/Factory/AppFactory.swift @@ -1,16 +1,22 @@ import Data import Domain +import FCM import Networking import Storage import Utils final class AppFactory { + let fcmTokenProvider: FCMTokenProviding let keychainFactory: KeychainFactory let networkFactory: NetworkFactory let dataFactory: DataFactory let domainFactory: DomainFactory - init(config: AppConfiguration) { + init( + config: AppConfiguration, + fcmTokenProvider: FCMTokenProviding = DefaultFCMFactory.shared.makeFCMTokenProvider() + ) { + self.fcmTokenProvider = fcmTokenProvider // keyChain let keychainFactory = DefaultKeychainFactory(bundleID: AppInfo.bundleID) diff --git a/Projects/App/Sources/Push/PushNotificationRegistrar.swift b/Projects/App/Sources/Push/PushNotificationRegistrar.swift new file mode 100644 index 00000000..bf8ee00f --- /dev/null +++ b/Projects/App/Sources/Push/PushNotificationRegistrar.swift @@ -0,0 +1,58 @@ +import Logger +import UIKit +import UserNotifications + +/// 푸시 권한 요청·APNs 등록은 App 레이어 책임. Core FCM은 토큰 수신만 담당한다. +final class PushNotificationRegistrar { + private enum UserDefaultsKey { + static let hasPresentedPushPermissionPrompt = "push.hasPresentedPermissionPrompt" + } + + func requestPermissionIfNeeded() { + if UserDefaults.standard.bool(forKey: UserDefaultsKey.hasPresentedPushPermissionPrompt) { + syncRegistrationIfAuthorized() + return + } + + UNUserNotificationCenter.current().requestAuthorization(options: [ + .alert, + .badge, + .sound, + ]) { granted, error in + UserDefaults.standard.set( + true, + forKey: UserDefaultsKey.hasPresentedPushPermissionPrompt + ) + + if let error { + Logger.shared.error( + "푸시 권한 요청 실패: \(error.localizedDescription)", + category: .general + ) + return + } + + Logger.shared.info( + "푸시 권한 \(granted ? "허용" : "거부")", + category: .general + ) + + guard granted else { return } + + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + /// 설정 앱에서 권한을 켠 뒤 앱으로 돌아온 경우 등 APNs 재등록 + func syncRegistrationIfAuthorized() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + guard settings.authorizationStatus == .authorized else { return } + + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + } +} diff --git a/Projects/App/Sources/Push/PushTokenSyncer.swift b/Projects/App/Sources/Push/PushTokenSyncer.swift new file mode 100644 index 00000000..09052ead --- /dev/null +++ b/Projects/App/Sources/Push/PushTokenSyncer.swift @@ -0,0 +1,20 @@ +import Logger + +protocol PushTokenSyncing: AnyObject { + func sync(fcmToken: String) async +} + +/// 서버 API 스펙이 연결되기 전까지 토큰 수신 지점만 고정한다. +final class DeferredPushTokenSyncer: PushTokenSyncing { + private var lastObservedToken: String? + + func sync(fcmToken: String) async { + guard lastObservedToken != fcmToken else { return } + + lastObservedToken = fcmToken + Logger.shared.info( + "FCM 토큰 수신됨. 서버 등록 API 연결 대기", + category: .general + ) + } +} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index c24e8cc9..f67973f8 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -5,6 +5,7 @@ import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? private var coordinator: RootCoordinator? + private let pushNotificationRegistrar = PushNotificationRegistrar() func scene( _ scene: UIScene, @@ -14,6 +15,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = (scene as? UIWindowScene) else { return } let config = AppConfiguration() + pushNotificationRegistrar.requestPermissionIfNeeded() + let factory = AppFactory(config: config) let nav = CommonNavigationController() @@ -29,6 +32,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.makeKeyAndVisible() } + func sceneDidBecomeActive(_ scene: UIScene) { + pushNotificationRegistrar.syncRegistrationIfAuthorized() + } + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { guard let url = URLContexts.first?.url else { return } AuthRedirect.handle(url: url) From e71996336e6bc4a8aaaafec5c3e7dd96ac685199 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 15:01:38 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=9A=94=EC=B2=AD=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EA=B7=B8=20=EB=AF=B8=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Sources/Push/PushNotificationRegistrar.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Projects/App/Sources/Push/PushNotificationRegistrar.swift b/Projects/App/Sources/Push/PushNotificationRegistrar.swift index bf8ee00f..95c474db 100644 --- a/Projects/App/Sources/Push/PushNotificationRegistrar.swift +++ b/Projects/App/Sources/Push/PushNotificationRegistrar.swift @@ -19,11 +19,6 @@ final class PushNotificationRegistrar { .badge, .sound, ]) { granted, error in - UserDefaults.standard.set( - true, - forKey: UserDefaultsKey.hasPresentedPushPermissionPrompt - ) - if let error { Logger.shared.error( "푸시 권한 요청 실패: \(error.localizedDescription)", @@ -32,6 +27,11 @@ final class PushNotificationRegistrar { return } + UserDefaults.standard.set( + true, + forKey: UserDefaultsKey.hasPresentedPushPermissionPrompt + ) + Logger.shared.info( "푸시 권한 \(granted ? "허용" : "거부")", category: .general From 5f1529210af67d382b3ec830408020088b838c2b Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 15:01:38 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20souzip-pr=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=EC=97=90=20PR=20Assignee=C2=B7Label=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A7=80=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 --- .codex/skills/souzip-pr/SKILL.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.codex/skills/souzip-pr/SKILL.md b/.codex/skills/souzip-pr/SKILL.md index 68336b77..5e966917 100644 --- a/.codex/skills/souzip-pr/SKILL.md +++ b/.codex/skills/souzip-pr/SKILL.md @@ -37,6 +37,9 @@ Use this skill only when the user explicitly asks for a PR. - Jira link 7. Push only the intended Story branch when needed for PR creation. 8. Create the PR only after readiness checks pass. +9. Set PR metadata immediately after creation: + - assignee: current user (`gh pr edit --add-assignee @me`) + - label: pick **one** from the allowed set below ## PR Title @@ -58,6 +61,32 @@ Use the existing GitHub PR template: Write the Goal and Plan slice as a short summary only. Actual Goal, Story, and Plan documents stay local under `docs/goals/`. +## PR Labels + +Use **only** these GitHub labels (same names as commit types): + +- `feat` +- `fix` +- `chore` +- `refactor` +- `docs` +- `test` + +Pick one label per PR: + +1. Story PR with user-facing change → `feat` (even if the branch has `chore` setup commits) +2. Bugfix Story → `fix` +3. Docs-only Story → `docs` +4. Test-only Story → `test` +5. Structure-only Story with no behavior change → `refactor` +6. Tooling, CI, harness-only Story → `chore` + +When unsure, prefer the Story intent over the majority commit type. + +```bash +gh pr edit --add-assignee @me --add-label