Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .codex/skills/souzip-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <number> --add-assignee @me --add-label <label>
```

## Output

- Story completion status
Expand All @@ -66,6 +95,7 @@ Actual Goal, Story, and Plan documents stay local under `docs/goals/`.
- PR body
- Push result when a branch push was needed
- PR result
- Assignee and label applied

## Boundaries

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Package.resolved
# Config files
Config/*.xcconfig
!Config/Example.xcconfig
Config/GoogleService-Info*.plist

# System files
.DS_Store
Expand Down
4 changes: 4 additions & 0 deletions Config/Example.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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)
12 changes: 12 additions & 0 deletions Projects/App/App-Debug.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
Expand Down
40 changes: 39 additions & 1 deletion Projects/App/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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(
Expand Down
8 changes: 7 additions & 1 deletion Projects/App/Sources/Factory/AppFactory.swift
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
58 changes: 58 additions & 0 deletions Projects/App/Sources/Push/PushNotificationRegistrar.swift
Original file line number Diff line number Diff line change
@@ -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
if let error {
Logger.shared.error(
"푸시 권한 요청 실패: \(error.localizedDescription)",
category: .general
)
return
Comment thread
gnoes-ios marked this conversation as resolved.
}
Comment on lines +22 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

권한 요청 실패 시에도 플래그를 설정해야 합니다.

현재 requestAuthorization 호출이 에러를 반환하면(네트워크 문제 등) 플래그가 설정되지 않은 채로 early return되어, 다음번 앱 실행 시 사용자가 다시 권한 프롬프트를 보게 됩니다. 권한 요청 시도 자체를 기록하려면 에러 발생 시에도 플래그를 설정해야 합니다.

🛡️ 제안 수정
         ]) { granted, error in
             if let error {
+                UserDefaults.standard.set(
+                    true,
+                    forKey: UserDefaultsKey.hasPresentedPushPermissionPrompt
+                )
                 Logger.shared.error(
                     "푸시 권한 요청 실패: \(error.localizedDescription)",
                     category: .general
                 )
                 return
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let error {
Logger.shared.error(
"푸시 권한 요청 실패: \(error.localizedDescription)",
category: .general
)
return
}
if let error {
UserDefaults.standard.set(
true,
forKey: UserDefaultsKey.hasPresentedPushPermissionPrompt
)
Logger.shared.error(
"푸시 권한 요청 실패: \(error.localizedDescription)",
category: .general
)
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/App/Sources/Push/PushNotificationRegistrar.swift` around lines 22 -
28, When handling the result of requestAuthorization, you currently return early
inside the error branch after logging via Logger.shared.error; update that
branch to also set the same "push permission requested" flag you set on success
(e.g., the pushPermissionRequested / hasRequestedPushPermissions UserDefaults
flag or the existing setter used elsewhere in PushNotificationRegistrar) before
returning so the app records that a request was attempted even when an error
occurs; keep the Logger.shared.error call, then persist the flag (using the same
method/path used in the success flow) and only then return.


UserDefaults.standard.set(
true,
forKey: UserDefaultsKey.hasPresentedPushPermissionPrompt
)

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()
}
}
}
}
20 changes: 20 additions & 0 deletions Projects/App/Sources/Push/PushTokenSyncer.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
7 changes: 7 additions & 0 deletions Projects/App/Sources/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -29,6 +32,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
window?.makeKeyAndVisible()
}

func sceneDidBecomeActive(_ scene: UIScene) {
pushNotificationRegistrar.syncRegistrationIfAuthorized()
}

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
AuthRedirect.handle(url: url)
Expand Down
11 changes: 11 additions & 0 deletions Projects/Core/FCM/Project.swift
Original file line number Diff line number Diff line change
@@ -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",
Comment on lines +8 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Firebase iOS SDK 11.14.0 Tuist SPM integration module verifier settings requirements

💡 Result:

You asked about “Firebase iOS SDK 11.14.0 Tuist SPM integration module verifier settings requirements”. Here’s what can be asserted from current sources about (a) Firebase 11.14.0 and (b) the Tuist/SPM settings patterns relevant to module verification/module-map problems. 1) Firebase iOS SDK 11.14.0 context Firebase Apple SDK 11.14.0 (June 3, 2025) is a released version of the “Firebase Apple SDK” distribution [1]. Release notes indicate it’s available as version 11.14.0 [1], and the SDK version is tracked in the repo release/compare tooling for 11.14.0 [2][3]. 2) What “module verifier settings” means (Xcode) Apple’s guidance for the Apple Clang Module Verifier is to enable the “Enable Module Verifier” build setting (Build Settings -> Apple Clang Module Verifier) and ensure “Supported Languages”/“Supported Language Dialects” match your requirements [4]. This is general Xcode guidance for identifying framework module issues before distribution [4]. 3) Tuist + SPM + Firebase: the practical “requirements” that map to module-verifier failures In Tuist, the most relevant build-setting category for Firebase SPM integration problems is the handling of module maps and DEFINES_MODULE. 3.1 Tuist generates/adjusts DEFINES_MODULE when a module map exists Tuist’s PackageInfoMapper logic sets “DEFINES_MODULE=NO” when it decides there is a module map coming from a directory/header/custom module-map setup (specifically when moduleMap is present and moduleMap kind is directory/header/custom) [5]. This is precisely the kind of setting that can affect module-map/module-verifier correctness. 3.2 Known Firebase-in-SPM issues in Tuist are usually solved by adjusting DEFINES_MODULE for specific targets There are multiple Tuist issue reports where developers ran into “DEFINE_MODULE was set, but no umbrella header could be found to generate the module map”, and one workaround was to set “DEFINES_MODULE”: false for Firebase-related targets (e.g., “Firebase”, “FirebaseAnalytics…”, “FirebaseCoreExtension”, etc.) via Tuist dependency targetSettings [6]. 3.3 Tuist had to change module-map generation behavior for a Firebase case A Tuist PR (“Fix integration of latest Firebase”) describes a Firebase Remote Config scenario where Tuist/module-map generation logic caused a compilation failure because a Swift-only target had publicHeadersSearchPath set but no headers; the fix was to “don’t generate a modulemap when a publicHeadersDir exists but it has no headers” and to “skip setting the DEFINES_MODULE=NO in those cases, so the objc target can consume the bridging header automatically generated by Xcode” [7]. So, if your question is “what Tuist module verifier settings requirements exist for Firebase 11.14.0 when integrating via SPM?”, the sourced answer is that Tuist’s SPM integration uses/affects module-map behavior through settings like DEFINES_MODULE, and Firebase-specific edge cases can require Tuist to avoid generating module maps (or avoid setting DEFINES_MODULE=NO) when there are no headers, so the objc targets can consume the bridging header automatically generated by Xcode [7]. 4) What you should actually configure in Tuist (actionable, grounded) A) Ensure your Xcode module verifier build settings are set correctly (general) - In Xcode, set “Enable Module Verifier” to Yes, and set “Supported Languages” and “Supported Language Dialects” to match what you need [4]. B) If you hit module-map/module-verifier failures specifically with Firebase SPM via Tuist, start by checking Tuist’s targetSettings for DEFINES_MODULE - Tuist issue workaround evidence shows setting DEFINES_MODULE false for many Firebase-related SPM products/targets can suppress “DEFINE_MODULE was set…” module-map generation warnings [6]. - Tuist’s own code shows it may set DEFINES_MODULE=NO in the presence of certain module-map configurations [5], and Tuist fixed a Firebase integration failure by skipping module-map generation and skipping DEFINES_MODULE=NO in the specific “publicHeadersDir exists but has no headers” case [7]. 5) Firebase SPM integration requirements that are not “module verifier” but are commonly required Firebase’s installation methods doc for Swift Package Manager states “Google Analytics requires adding the -ObjC linker flag to your target's build settings if included transitively.” [8]. Separately, Tuist’s dependencies guide states Firebase has a “Requires -ObjC linker flag” requirement (in its Firebase special configurations section) [9]. Limitations / what I could not find in the sources I did not find a Firebase 11.14.0–specific, single canonical “Tuist module verifier settings requirements” document that enumerates exact Tuist settings to configure for 11.14.0 specifically. Instead, the sources describe (1) general Xcode module verifier build settings [4], and (2) Tuist/Firebase integration failures and fixes centered on module-map + DEFINES_MODULE behavior [7][6][5], which is the most direct “requirement” relationship to module verifier/module map issues. If you share your Tuist snippet (how you declare Firebase as.remote/.external, and whether you’re using Tuist’s swiftPackageManager dependency integration or XcodeProj-based integration) and the exact module verifier warning/error text, I can map it to the correct Tuist targetSettings adjustments consistent with the above evidence.

Citations:


Firebase 모듈 검증기 비활성화(ENABLE_MODULE_VERIFIER/CLANG_ENABLE_MODULE_VERIFIER)가 정말 필요한지 로그 근거로 재확인 요청

  • Firebase iOS SDK + Tuist/SPM 통합에서 일반적으로 문제가 되는 지점은 모듈 검증기 자체가 아니라 모듈 맵/DEFINES_MODULE 쪽인 경우가 많습니다(전역 모듈 검증기 비활성화가 필수라는 문서 근거는 확인되지 않음).
  • Projects/Core/FCM/Project.swift(8-9)에서 해당 설정이 필요한지 빌드 로그의 “module verifier”/“module map(umbrella header 등)” 관련 정확한 경고·에러 문구로 확인하고, 가능하면 원인에 맞춰 타겟별로 모듈 맵/DEFINES_MODULE를 조정하는 방향을 우선 검토해주세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/Core/FCM/Project.swift` around lines 8 - 9, 현재 Project.swift에 전역으로
ENABLE_MODULE_VERIFIER/CLANG_ENABLE_MODULE_VERIFIER를 "NO"로 설정한 근거가 부족하므로 빌드를 재현해
Xcode/Tuist 빌드 로그에서 "module verifier" 또는 "module map"/"umbrella
header"/"DEFINES_MODULE" 관련 정확한 경고·에러 문자열을 캡처하세요; 로그 메시지가 모듈 맵(또는 DEFINES_MODULE
누락) 문제를 가리키면 Project.swift에서 전역 모듈 검증기 비활성화 대신 해당 타겟의 모듈 맵 또는 DEFINES_MODULE
속성(타겟 정의 코드에서 DEFINES_MODULE 설정 또는 custom modulemap 사용)을 수정하여 문제를 해결하고, 만약 로그가
실제로 모듈 검증기 자체 오류를 지목하면 영향을 받는 특정 타겟에만
ENABLE_MODULE_VERIFIER/CLANG_ENABLE_MODULE_VERIFIER을 끄도록 변경해 전역 설정을 제거하세요.

]
)
87 changes: 87 additions & 0 deletions Projects/Core/FCM/Sources/FCMManager.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
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)
}
}
11 changes: 11 additions & 0 deletions Projects/Core/FCM/Sources/FCMTokenProviding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

public protocol FCMTokenProviding: AnyObject {
func currentToken() async -> String?

/// 토큰 갱신 스트림. 구독 시점에 이미 토큰이 있으면 즉시 한 번 방출한다.
func fcmTokenUpdates() -> AsyncStream<String>

func configure()
func setAPNSToken(_ deviceToken: Data)
}
Loading
Loading