Skip to content
Open
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
3 changes: 2 additions & 1 deletion .codex/skills/souzip-plan/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Use this skill to create a Plan that can be implemented and verified in one sess
4. List implementation tasks.
5. Add verification commands:
- default: `docs/harness/scripts/verify.sh plan`
- related tests with `VERIFY_TEST_TARGETS` or `VERIFY_TEST_SCHEME` when applicable
- related module tests with `VERIFY_TEST_SCHEME` when applicable, for example `VERIFY_TEST_SCHEME="Domain" docs/harness/scripts/verify.sh plan`
- use `VERIFY_TEST_TARGETS` only with `VERIFY_TEST_SCHEME` to narrow `xcodebuild -only-testing`
6. Add stop conditions for structural changes.
7. Wait for user approval and the explicit "구현해" instruction before code edits.

Expand Down
5 changes: 4 additions & 1 deletion .codex/skills/souzip-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ Use this skill to verify completed work and classify failures.
2. Run the matching verification command when appropriate:
- Plan: `docs/harness/scripts/verify.sh plan`
- Story: `docs/harness/scripts/verify.sh story`
3. Include related tests with `VERIFY_TEST_TARGETS` or `VERIFY_TEST_SCHEME` when applicable.
3. Include related tests with `VERIFY_TEST_SCHEME` when applicable.
- For module tests, prefer `VERIFY_TEST_SCHEME="Domain" docs/harness/scripts/verify.sh plan`.
- The script routes module schemes to `xcodebuild test` directly because `tuist test` can miss module tests via selective testing or workspace scheme resolution.
- Use `VERIFY_TEST_TARGETS` only as an optional `xcodebuild -only-testing` filter together with `VERIFY_TEST_SCHEME`.
4. Record passed checks, failed checks, skipped checks, and skip reasons.
5. Classify failures:
- Plan scope: fix and rerun the same verification.
Expand Down
11 changes: 6 additions & 5 deletions Projects/App/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ 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 {
AppContainer.bootstrap(config: AppConfiguration())

let fcmTokenProvider = AppContainer.shared.factory.fcmTokenProvider
fcmTokenProvider.configure()
observeFCMTokenUpdates()
return true
Expand All @@ -21,7 +22,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
fcmTokenProvider.setAPNSToken(deviceToken)
AppContainer.shared.factory.fcmTokenProvider.setAPNSToken(deviceToken)
}

func application(
Expand All @@ -37,8 +38,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
private func observeFCMTokenUpdates() {
pushTokenSyncTask?.cancel()

let tokenUpdates = fcmTokenProvider.fcmTokenUpdates()
let pushTokenSyncer = pushTokenSyncer
let tokenUpdates = AppContainer.shared.factory.fcmTokenProvider.fcmTokenUpdates()
let pushTokenSyncer = AppContainer.shared.pushTokenSyncer

pushTokenSyncTask = Task {
for await token in tokenUpdates {
Expand Down
28 changes: 28 additions & 0 deletions Projects/App/Sources/Factory/AppContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
final class AppContainer {
private static var stored: AppContainer?

static var shared: AppContainer {
guard let stored else {
fatalError("AppContainer.bootstrap(config:)를 먼저 호출해야 합니다.")
}
return stored
}

let factory: AppFactory

private lazy var pushTokenSyncerInstance: PushTokenSyncing = factory.makePushTokenSyncer()

var pushTokenSyncer: PushTokenSyncing {
pushTokenSyncerInstance
}

private init(config: AppConfiguration) {
factory = AppFactory(config: config)
}

static func bootstrap(config: AppConfiguration) {
guard stored == nil else { return }

stored = AppContainer(config: config)
}
}
13 changes: 13 additions & 0 deletions Projects/App/Sources/Factory/AppFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,17 @@ final class AppFactory {
self.dataFactory = dataFactory
self.domainFactory = domainFactory
}

func makePushTokenSyncer() -> PushTokenSyncing {
let deviceIdStore = DeviceIdStore(
keychain: keychainFactory.makeKeychainStorage()
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Split device ID stores desync

Medium Severity

Registration reads deviceId from a DeviceIdStore created in AppFactory, while logout deactivation uses a separate cached store in DataFactory. Two actors can each mint a UUID before either writes Keychain, so register and deactivate may use different IDs.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0e87720. Configure here.


return DefaultPushTokenSyncer(
registerFCMToken: domainFactory.makeRegisterFCMTokenUseCase(),
checkFullAuthentication: domainFactory.makeCheckFullAuthenticationUseCase(),
fcmTokenProvider: fcmTokenProvider,
deviceIdStore: deviceIdStore
)
}
}
85 changes: 85 additions & 0 deletions Projects/App/Sources/Push/DefaultPushTokenSyncer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Domain
import FCM
import Logger
import Storage

protocol PushTokenSyncing: AnyObject {
func sync(fcmToken: String) async
func syncCurrentTokenIfAuthenticated() async
}

actor DefaultPushTokenSyncer: PushTokenSyncing {
private let registerFCMToken: RegisterFCMTokenUseCase
private let checkFullAuthentication: CheckFullAuthenticationUseCase
private let fcmTokenProvider: FCMTokenProviding
private let deviceIdStore: DeviceIdProviding

private struct RegistrationKey: Hashable {
let token: String
let deviceId: String
}

private var inFlightRegistrations = Set<RegistrationKey>()

init(
registerFCMToken: RegisterFCMTokenUseCase,
checkFullAuthentication: CheckFullAuthenticationUseCase,
fcmTokenProvider: FCMTokenProviding,
deviceIdStore: DeviceIdProviding
) {
self.registerFCMToken = registerFCMToken
self.checkFullAuthentication = checkFullAuthentication
self.fcmTokenProvider = fcmTokenProvider
self.deviceIdStore = deviceIdStore
}

func sync(fcmToken: String) async {
await registerIfNeeded(fcmToken: fcmToken)
}

func syncCurrentTokenIfAuthenticated() async {
guard let fcmToken = await fcmTokenProvider.currentToken() else { return }

await registerIfNeeded(fcmToken: fcmToken)
}

private func registerIfNeeded(fcmToken: String) async {
guard await checkFullAuthentication.execute() else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No FCM sync after login

Medium Severity

FCM server registration runs only when the token stream emits or on sceneDidBecomeActive. If the FCM token is already known while the user is logged out, registerIfNeeded returns early and nothing retries after login or auto-login until the app backgrounds or the token changes.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0e87720. Configure here.


let deviceId: String
do {
deviceId = try await deviceIdStore.deviceId()
} catch {
Logger.shared.error(
"FCM 토큰 등록용 기기 ID 준비 실패: \(error.localizedDescription)",
category: .general
)
return
}

let registrationKey = RegistrationKey(token: fcmToken, deviceId: deviceId)
guard !inFlightRegistrations.contains(registrationKey) else { return }
inFlightRegistrations.insert(registrationKey)
defer { inFlightRegistrations.remove(registrationKey) }

let deviceInfo = DeviceInfoProvider.current()
let registration = FCMRegistration(
token: fcmToken,
deviceId: deviceId,
deviceType: .ios,
deviceModel: deviceInfo.deviceModel,
osVersion: deviceInfo.osVersion,
appVersion: deviceInfo.appVersion
)

do {
try await registerFCMToken.execute(registration: registration)
Logger.shared.info("FCM 토큰 서버 등록 완료", category: .general)
} catch {
Logger.shared.error(
"FCM 토큰 서버 등록 실패: \(error.localizedDescription)",
category: .general
)
}
}
}
29 changes: 29 additions & 0 deletions Projects/App/Sources/Push/DeviceInfoProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import UIKit
import Utils

struct DeviceInfo {
let deviceModel: String
let osVersion: String
let appVersion: String
}

enum DeviceInfoProvider {
static func current() -> DeviceInfo {
DeviceInfo(
deviceModel: deviceModel(),
osVersion: UIDevice.current.systemVersion,
appVersion: AppInfo.version
)
}

private static func deviceModel() -> String {
var systemInfo = utsname()
uname(&systemInfo)

return withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
String(cString: $0)
}
}
}
}
20 changes: 0 additions & 20 deletions Projects/App/Sources/Push/PushTokenSyncer.swift

This file was deleted.

8 changes: 6 additions & 2 deletions Projects/App/Sources/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
) {
guard let windowScene = (scene as? UIWindowScene) else { return }

let config = AppConfiguration()
AppContainer.bootstrap(config: AppConfiguration())
pushNotificationRegistrar.requestPermissionIfNeeded()

let factory = AppFactory(config: config)
let factory = AppContainer.shared.factory

let nav = CommonNavigationController()

Expand All @@ -34,6 +34,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {

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

Task {
await AppContainer.shared.pushTokenSyncer.syncCurrentTokenIfAuthenticated()
}
}

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
Expand Down
3 changes: 3 additions & 0 deletions Projects/Core/Storage/Sources/Device/DeviceIdProviding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
public protocol DeviceIdProviding: Sendable {
func deviceId() async throws -> String
}
33 changes: 33 additions & 0 deletions Projects/Core/Storage/Sources/Device/DeviceIdStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation
import Utils

public actor DeviceIdStore: DeviceIdProviding {
private let keychain: KeychainStorage
private var cachedDeviceId: String?

public init(keychain: KeychainStorage) {
self.keychain = keychain
}

public func deviceId() async throws -> String {
if let cachedDeviceId {
return cachedDeviceId
}

do {
if let stored: String = try await keychain.get(forKey: KeychainKey.deviceId) {
cachedDeviceId = stored
return stored
}
} catch KeychainError.itemNotFound {
// 저장된 ID가 없을 때만 새 ID를 발급합니다.
} catch {
throw error
}

let newDeviceId = UUID().uuidString
try await keychain.save(newDeviceId, forKey: KeychainKey.deviceId)
cachedDeviceId = newDeviceId
return newDeviceId
}
}
8 changes: 8 additions & 0 deletions Projects/Data/Sources/FCM/DTO/FCMRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
struct RegisterFCMTokenRequest: Encodable {
let token: String
let deviceId: String
let deviceType: String
let deviceModel: String?
let osVersion: String?
let appVersion: String?
}
14 changes: 14 additions & 0 deletions Projects/Data/Sources/FCM/DTO/FCMRequestMapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Domain

enum FCMRequestMapper {
static func toRegisterRequest(_ registration: FCMRegistration) -> RegisterFCMTokenRequest {
RegisterFCMTokenRequest(
token: registration.token,
deviceId: registration.deviceId,
deviceType: registration.deviceType.rawValue,
deviceModel: registration.deviceModel,
osVersion: registration.osVersion,
appVersion: registration.appVersion
)
}
}
31 changes: 31 additions & 0 deletions Projects/Data/Sources/FCM/DataSource/FCMRemoteDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Networking

protocol FCMRemoteDataSource {
func register(request: RegisterFCMTokenRequest) async throws
func deactivate(deviceId: String) async throws
}

final class DefaultFCMRemoteDataSource: FCMRemoteDataSource {
private let authed: NetworkClient

init(authed: NetworkClient) {
self.authed = authed
}

func register(request: RegisterFCMTokenRequest) async throws {
let endpoint = FCMEndpoint.register(
token: request.token,
deviceId: request.deviceId,
deviceType: request.deviceType,
deviceModel: request.deviceModel,
osVersion: request.osVersion,
appVersion: request.appVersion
)
let _: EmptyResponse = try await authed.request(endpoint)
}

func deactivate(deviceId: String) async throws {
let endpoint = FCMEndpoint.deactivate(deviceId: deviceId)
let _: EmptyResponse = try await authed.request(endpoint)
}
}
Loading
Loading