From 43a604b77c8500673ffded20db13dfa7433e95a8 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Tue, 11 Aug 2026 03:23:04 +0900 Subject: [PATCH 01/16] =?UTF-8?q?[#81]=20refactor:=20FetchSecretUseCase?= =?UTF-8?q?=EC=97=90=EC=84=9C=20RevealSecretPayloadUseCase=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revealPayload가 FetchSecretUseCase에 같이 있으면, fetch/count만 필요한 소비처 (SidebarClient 등)도 AuthenticateUseCase까지 억지로 조립해야 했다. RevealSecretPayloadUseCase로 분리해 FetchSecretUseCaseImpl은 repository만 필요하게 됨 — SidebarClient+Live에서 인증/알림 관련 wiring이 전부 사라짐. SecretExpiryNotificationClient도 삭제하고 SecretClient+Live/SecretManagementClient+Live가 각자 ScheduleSecretExpiryNotificationsUseCase를 직접 조립하도록 정리(어차피 Reducer가 아니라 다른 Live 파일에서 static으로만 참조되던 Client라 TCA 계층이 불필요했음). --- .../Composition/Dependencies/SecretClient+Live.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift index 07e2d4ec..6922fd7b 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift @@ -11,8 +11,12 @@ extension SecretClient: @retroactive DependencyKey { let secretRepo = LiveRepositories.secret let projectRepo = LiveRepositories.project let cryptoService: any SecretCryptoService = SecretCryptoServiceImpl() + let authenticationService: any UserAuthenticationService = LocalUserAuthenticationServiceImpl() let notificationService: any SecurityNotificationService = SecurityNotificationServiceImpl() - let authenticateUseCase: any AuthenticateUseCase = LiveUseCases.authenticate + let authenticateUseCase: any AuthenticateUseCase = AuthenticateUseCaseImpl( + authenticationService: authenticationService, + notificationService: notificationService + ) let expiryUseCase: any ScheduleSecretExpiryNotificationsUseCase = ScheduleSecretExpiryNotificationsUseCaseImpl( repository: secretRepo, notificationService: notificationService From c84cafeb9daefe50038be99278e17dc77f04c2e3 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 16:40:00 +0900 Subject: [PATCH 02/16] =?UTF-8?q?[#89]=20feat:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EC=9E=A0=EA=B8=88=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MainFeature에 didTapLock 액션과 delegate(.lockRequested) 추가 - AppFeature가 lockRequested를 받으면 main을 지우고 locked로 전환 - MainView에 잠금 버튼 오버레이 추가, 3컬럼 NavigationSplitView의 columnVisibility 바인딩 제거 --- .../Sources/Features/AppFeature.swift | 5 ++ .../Sources/Features/Main/MainFeature.swift | 11 ++-- .../Sources/Features/Main/MainView.swift | 52 +++++++++++-------- .../Tests/AppFeatureTests.swift | 43 +++++++++++++++ .../Tests/Main/MainFeatureTests.swift | 12 +++++ 5 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 Projects/DVPresentation/Tests/AppFeatureTests.swift diff --git a/Projects/DVPresentation/Sources/Features/AppFeature.swift b/Projects/DVPresentation/Sources/Features/AppFeature.swift index e05e0980..931b887a 100644 --- a/Projects/DVPresentation/Sources/Features/AppFeature.swift +++ b/Projects/DVPresentation/Sources/Features/AppFeature.swift @@ -105,6 +105,11 @@ public struct AppFeature { case .locked: return .none + case .main(.delegate(.lockRequested)): + state.main = nil + state.locked = .init() + return .none + case .main: return .none diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 4dc9221c..e03f9bec 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -1,7 +1,5 @@ // Copyright © 2026 Devault. All rights reserved -import SwiftUI - import ComposableArchitecture // MARK: - MainFeature @@ -13,7 +11,6 @@ public struct MainFeature { @ObservableState public struct State: Equatable { - public var columnVisibility: NavigationSplitViewVisibility = .all var sidebar: SidebarFeature.State = .init() var secretList: SecretListFeature.State = .init(collection: .all) /// sheet가 아닌 2-column NavigationSplitView 전환 용도이므로 @Presents 미사용 @@ -35,6 +32,7 @@ public struct MainFeature { case binding(BindingAction) case task + case didTapLock // MARK: - Child @@ -49,7 +47,9 @@ public struct MainFeature { case delegate(Delegate) - public enum Delegate: Equatable {} + public enum Delegate: Equatable { + case lockRequested + } } // MARK: - Dependencies @@ -78,6 +78,9 @@ public struct MainFeature { case .task: return .none + case .didTapLock: + return .send(.delegate(.lockRequested)) + case .sidebar(.delegate(let delegate)): return handleSidebarDelegate(&state, delegate: delegate) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index 557ad052..096b30f6 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -24,43 +24,32 @@ struct MainView: View { ) { createProjectStore in CreateProjectView(store: createProjectStore) } + .overlay(alignment: .topTrailing) { + lockButton + .padding(16) + .ignoresSafeArea(edges: .top) + } } } // MARK: - Subviews extension MainView { - + @ViewBuilder private var content: some View { - if let createSecretStore = store.scope(state: \.createSecret, action: \.createSecret) { - // 사이드바 + 시크릿 생성 폼 (2컬럼) - NavigationSplitView { - sidebarColumn - } detail: { - CreateSecretView(store: createSecretStore) - // max는 주지 않는다 — 컬럼이 창을 채우지 못하면 윈도우 배경이 드러난다. - // 폼 폭 상한은 컬럼이 아니라 `CreateSecretView` 안의 `formMaxWidth()`가 담당한다. - // min 520은 CreateSecretView 자체 제약과 동일 — apiKeyToken 3-radio 헤더 폭이 지배한다. - .navigationSplitViewColumnWidth( - min: 520, - ideal: FormLayoutMetrics.maxFormWidth - ) - } - .navigationSplitViewStyle(.balanced) - .toolbarBackground(.hidden, for: .windowToolbar) - } else if let selectStore = store.scope(state: \.selectSecretType, action: \.selectSecretType) { - // 사이드바 + 타입 선택 그리드 (2컬럼) + if store.createSecret != nil || store.selectSecretType != nil { + // 사이드바 + 시크릿 생성 폼/타입 선택 그리드 (2컬럼) NavigationSplitView { sidebarColumn } detail: { - SelectSecretTypeView(store: selectStore) + twoColumnDetail } .navigationSplitViewStyle(.balanced) .toolbarBackground(.hidden, for: .windowToolbar) } else { // 사이드바 + 시크릿 목록 + 상세 (3컬럼) - NavigationSplitView(columnVisibility: $store.columnVisibility) { + NavigationSplitView { sidebarColumn } content: { contentColumn @@ -72,6 +61,15 @@ extension MainView { } } + @ViewBuilder + private var twoColumnDetail: some View { + if let createSecretStore = store.scope(state: \.createSecret, action: \.createSecret) { + CreateSecretView(store: createSecretStore) + } else if let selectStore = store.scope(state: \.selectSecretType, action: \.selectSecretType) { + SelectSecretTypeView(store: selectStore) + } + } + private var sidebarColumn: some View { SidebarView(store: store.scope(state: \.sidebar, action: \.sidebar)) .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 270) @@ -99,6 +97,18 @@ extension MainView { // 폼 폭 상한은 컬럼이 아니라 `SecretDetailView` 안의 `formMaxWidth()`가 담당한다. .navigationSplitViewColumnWidth(min: 420, ideal: 480) } + + private var lockButton: some View { + Button { + store.send(.didTapLock) + } label: { + Image(systemName: "lock") + .foregroundStyle(Color.dv(.vaultGreen)) + .dvFont(.headingLG) + } + .buttonStyle(.plain) + .accessibilityLabel(String.module("Lock App")) + } } // MARK: - Preview diff --git a/Projects/DVPresentation/Tests/AppFeatureTests.swift b/Projects/DVPresentation/Tests/AppFeatureTests.swift new file mode 100644 index 00000000..3984f04e --- /dev/null +++ b/Projects/DVPresentation/Tests/AppFeatureTests.swift @@ -0,0 +1,43 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import Foundation +import Testing + +@testable import DVPresentation + +@MainActor +@Suite("AppFeature") +struct AppFeatureTests { + + @Test("task는 온보딩을 완료했으면 locked 상태로 시작한다") + func taskStartsLockedWhenOnboardingCompleted() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.appLaunchClient.hasCompletedOnboarding = { true } + $0.appLaunchClient.requestNotificationAuthorization = { true } + $0.appLaunchClient.syncExpiryNotifications = { } + } + + await store.send(.task) { + $0.locked = .init() + } + } + + @Test("main의 lockRequested delegate는 main을 지우고 locked를 새로 연다") + func lockRequestedLocksApp() async { + var initial = AppFeature.State() + initial.main = .init() + + let store = TestStore(initialState: initial) { + AppFeature() + } + + await store.send(.main(.didTapLock)) + await store.receive(.main(.delegate(.lockRequested))) { + $0.main = nil + $0.locked = .init() + } + } +} diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 1acd6270..6421d6fb 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -487,4 +487,16 @@ struct MainFeatureTests { $0.sidebar.countsState = .loaded(SecretCounts()) } } + + // MARK: - Lock + + @Test("didTapLock은 lockRequested를 delegate로 알린다") + func didTapLockSendsDelegate() async { + let store = TestStore(initialState: MainFeature.State()) { + MainFeature() + } + + await store.send(.didTapLock) + await store.receive(.delegate(.lockRequested)) + } } From d0c97abe2bee42816c68cac4707749406b6500ac Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 16:40:51 +0900 Subject: [PATCH 03/16] =?UTF-8?q?[#89]=20refactor:=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=AC=B8=EA=B5=AC=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20Presentation=20=EA=B3=84=EC=B8=B5=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecurityNotification.abnormalAccess가 reason: String 대신 kind/threshold 값만 갖도록 변경 - SecurityNotificationServiceImpl이 makeContent 클로저를 주입받아 문구를 생성하도록 변경 - SecurityNotification.moduleContent(for:)(DVPresentation)에서 실제 문구를 만들어 주입 - LiveServices.securityNotification 단일 인스턴스로 통합해 중복 생성 제거 - LiveSettingsRepository를 LiveRepositories.settings로 통합 --- .../SecurityNotificationServiceImpl.swift | 31 ++++++++--------- .../Notification/SecurityNotification.swift | 10 ++++-- .../AuthenticateUseCaseImpl.swift | 2 +- .../CopySensitiveValueUseCaseImpl.swift | 2 +- .../AuthenticateUseCaseImplTests.swift | 2 +- .../SecurityNotification+Module.swift | 33 +++++++++++++++++++ .../Dependencies/AppLaunchClient+Live.swift | 8 ++--- .../Dependencies/LiveRepositories.swift | 1 + .../Dependencies/LiveServices.swift | 12 +++++++ .../Dependencies/LiveUseCases.swift | 2 +- .../Dependencies/SecretClient+Live.swift | 9 ++--- .../SecretManagementClient+Live.swift | 3 +- .../Composition/LiveSettingsRepository.swift | 9 ----- .../Sources/Composition/LiveStorage.swift | 2 +- 14 files changed, 79 insertions(+), 47 deletions(-) create mode 100644 Projects/DVPresentation/Sources/Localization/SecurityNotification+Module.swift create mode 100644 Projects/Devault/Sources/Composition/Dependencies/LiveServices.swift delete mode 100644 Projects/Devault/Sources/Composition/LiveSettingsRepository.swift diff --git a/Projects/DVData/Sources/ServiceImpl/Notification/SecurityNotificationServiceImpl.swift b/Projects/DVData/Sources/ServiceImpl/Notification/SecurityNotificationServiceImpl.swift index d786f6e7..73ad28e5 100644 --- a/Projects/DVData/Sources/ServiceImpl/Notification/SecurityNotificationServiceImpl.swift +++ b/Projects/DVData/Sources/ServiceImpl/Notification/SecurityNotificationServiceImpl.swift @@ -7,9 +7,16 @@ import DVDomain public struct SecurityNotificationServiceImpl: SecurityNotificationService { private let center: UNUserNotificationCenter + private let makeContent: @Sendable (SecurityNotification) -> (title: String, body: String) - public init(center: UNUserNotificationCenter = .current()) { + /// `makeContent`는 Data 모듈에서 Presentation의 로컬라이제이션 카탈로그에 접근할 수 없어 외부에서 주입받는다. + /// 이 타입이 직접 가질 수 없는 관심사를 순수 함수로 밖에서 받는다. + public init( + center: UNUserNotificationCenter = .current(), + makeContent: @escaping @Sendable (SecurityNotification) -> (title: String, body: String) + ) { self.center = center + self.makeContent = makeContent } public func requestAuthorization() async throws -> Bool { @@ -23,7 +30,7 @@ public struct SecurityNotificationServiceImpl: SecurityNotificationService { public func notify(_ notification: SecurityNotification) async throws { let request = UNNotificationRequest( identifier: UUID().uuidString, - content: Self.makeContent(for: notification), + content: makeUNContent(for: notification), trigger: nil // trigger가 nil이면 즉시 발송 ) do { @@ -45,7 +52,7 @@ public struct SecurityNotificationServiceImpl: SecurityNotificationService { ) let notificationRequest = UNNotificationRequest( identifier: request.identifier, - content: Self.makeContent(for: request.notification), + content: makeUNContent(for: request.notification), trigger: trigger ) do { @@ -63,21 +70,11 @@ public struct SecurityNotificationServiceImpl: SecurityNotificationService { // MARK: - Private private extension SecurityNotificationServiceImpl { - static func makeContent(for notification: SecurityNotification) -> UNMutableNotificationContent { + func makeUNContent(for notification: SecurityNotification) -> UNMutableNotificationContent { + let (title, body) = makeContent(notification) let content = UNMutableNotificationContent() - switch notification { - case .abnormalAccess(let reason): - content.title = "비정상 접근이 감지됐어요" - content.body = reason - - case .clipboardExceeded(let seconds): - content.title = "클립보드를 정리했어요" - content.body = "복사된 값이 \(seconds)초 넘게 남아 있어 자동으로 지웠어요." - - case .secretExpiresSoon(_, let daysBefore): - content.title = "Secret 만료가 다가와요" - content.body = "저장된 Secret이 \(daysBefore)일 후 만료돼요." - } + content.title = title + content.body = body content.sound = .default return content } diff --git a/Projects/DVDomain/Sources/Service/Interface/Notification/SecurityNotification.swift b/Projects/DVDomain/Sources/Service/Interface/Notification/SecurityNotification.swift index b3484732..4f0b5241 100644 --- a/Projects/DVDomain/Sources/Service/Interface/Notification/SecurityNotification.swift +++ b/Projects/DVDomain/Sources/Service/Interface/Notification/SecurityNotification.swift @@ -4,14 +4,20 @@ import Foundation /// 로컬 알림으로 사용자에게 전달할 보안 이벤트입니다. public enum SecurityNotification: Equatable, Sendable { - /// 잠금 해제 반복 실패 등 비정상적인 접근이 감지됨 - case abnormalAccess(reason: String) + /// 잠금 해제 반복 실패 등 비정상적인 접근이 감지됨. 문구는 소비처(Presentation)가 만드므로 여기선 원인 판단에 필요한 값만 들고 있는다. + case abnormalAccess(kind: AbnormalAccessKind, threshold: Int) /// 클립보드에 민감 값이 30초 이상 남아 있어 정리함 case clipboardExceeded(seconds: Int) /// Secret이 곧 만료됨. 인증 없이 노출될 수 있어 name은 포함하지 않음. case secretExpiresSoon(secretID: UUID, daysBefore: Int) } +/// `abnormalAccess`를 유발한 반복 행위의 종류입니다. 문구는 소비처가 만들고, 여기선 분기 값만 제공합니다. +public enum AbnormalAccessKind: Equatable, Sendable { + case authenticationFailure + case repeatedCopy +} + /// 특정 시각에 발송되도록 예약하는 알림 요청입니다. public struct ScheduledSecurityNotification: Equatable, Sendable { public let identifier: String diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Authentication/AuthenticateUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Authentication/AuthenticateUseCaseImpl.swift index 29169590..f9dcdda6 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Authentication/AuthenticateUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Authentication/AuthenticateUseCaseImpl.swift @@ -46,7 +46,7 @@ public actor AuthenticateUseCaseImpl: AuthenticateUseCase { if abnormalAccessMonitor.recordAccess(at: now()) { do { try await notificationService.notify( - .abnormalAccess(reason: "짧은 시간 안에 인증 실패가 \(Self.abnormalAccessThreshold)회 이상 반복됨") + .abnormalAccess(kind: .authenticationFailure, threshold: Self.abnormalAccessThreshold) ) // alert에 상관없이 알림 스킵을 막기 위해 약간의 지연 추가 diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Clipboard/CopySensitiveValueUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Clipboard/CopySensitiveValueUseCaseImpl.swift index 18238fbb..96e8cf27 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Clipboard/CopySensitiveValueUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Clipboard/CopySensitiveValueUseCaseImpl.swift @@ -56,7 +56,7 @@ public actor CopySensitiveValueUseCaseImpl: CopySensitiveValueUseCase { if abnormalAccessMonitor.recordAccess(at: now()) { do { try await notificationService.notify( - .abnormalAccess(reason: "짧은 시간 안에 값 복사가 \(Self.abnormalAccessThreshold)회 이상 반복됨") + .abnormalAccess(kind: .repeatedCopy, threshold: Self.abnormalAccessThreshold) ) } catch { // 알림 실패는 복사 자체를 실패시키면 안 되므로 로깅만 diff --git a/Projects/DVDomain/Tests/Core/UseCase/Authentication/AuthenticateUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Authentication/AuthenticateUseCaseImplTests.swift index af2c64f4..f5a926d4 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Authentication/AuthenticateUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Authentication/AuthenticateUseCaseImplTests.swift @@ -60,7 +60,7 @@ struct AuthenticateUseCaseImplTests { #expect(notificationService.notified.count == 1) #expect(notificationService.notified.first == .abnormalAccess( - reason: "짧은 시간 안에 인증 실패가 3회 이상 반복됨" + kind: .authenticationFailure, threshold: 3 )) } diff --git a/Projects/DVPresentation/Sources/Localization/SecurityNotification+Module.swift b/Projects/DVPresentation/Sources/Localization/SecurityNotification+Module.swift new file mode 100644 index 00000000..64296496 --- /dev/null +++ b/Projects/DVPresentation/Sources/Localization/SecurityNotification+Module.swift @@ -0,0 +1,33 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain + +public extension SecurityNotification { + /// `SecurityNotificationServiceImpl`(DVData)에 주입하는 알림 문구 팩토리. DVData가 접근 못 하는 로컬라이제이션 카탈로그를 이 모듈에서 대신 룩업한다. + @Sendable + static func moduleContent(for notification: SecurityNotification) -> (title: String, body: String) { + switch notification { + case .abnormalAccess(let kind, let threshold): + let body: String + switch kind { + case .authenticationFailure: + body = String.module("Authentication failed \(threshold) times in a short period") + case .repeatedCopy: + body = String.module("A value was copied \(threshold) times in a short period") + } + return (String.module("Abnormal access detected"), body) + + case .clipboardExceeded(let seconds): + return ( + String.module("Clipboard cleared"), + String.module("The copied value was cleared after being on the clipboard for over \(seconds) seconds.") + ) + + case .secretExpiresSoon(_, let daysBefore): + return ( + String.module("A secret is expiring soon"), + String.module("A saved secret will expire in \(daysBefore) days.") + ) + } + } +} diff --git a/Projects/Devault/Sources/Composition/Dependencies/AppLaunchClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/AppLaunchClient+Live.swift index fee5c9c9..b8a70fc0 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/AppLaunchClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/AppLaunchClient+Live.swift @@ -8,15 +8,13 @@ import DVPresentation extension AppLaunchClient: @retroactive DependencyKey { public static let liveValue: AppLaunchClient = { - let settingsRepository: any SettingsRepository = SettingsRepositoryImpl() let onboardingStatusUseCase: any OnboardingStatusUseCase = OnboardingStatusUseCaseImpl( - repository: settingsRepository + repository: LiveRepositories.settings ) - let notificationService: any SecurityNotificationService = SecurityNotificationServiceImpl() let expiryUseCase: any ScheduleSecretExpiryNotificationsUseCase = ScheduleSecretExpiryNotificationsUseCaseImpl( repository: LiveRepositories.secret, - notificationService: notificationService + notificationService: LiveServices.securityNotification ) return AppLaunchClient( @@ -28,7 +26,7 @@ extension AppLaunchClient: @retroactive DependencyKey { }, requestNotificationAuthorization: { do { - return try await notificationService.requestAuthorization() + return try await LiveServices.securityNotification.requestAuthorization() } catch { Log.warn("알림 권한 요청 실패: \(error)", category: .notification) return false diff --git a/Projects/Devault/Sources/Composition/Dependencies/LiveRepositories.swift b/Projects/Devault/Sources/Composition/Dependencies/LiveRepositories.swift index 84789049..466a6e4d 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/LiveRepositories.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/LiveRepositories.swift @@ -13,4 +13,5 @@ enum LiveRepositories { static let project: any ProjectRepository = ProjectRepositoryImpl( modelContainer: LiveStorage.shared.modelContainer ) + static let settings: any SettingsRepository = SettingsRepositoryImpl() } diff --git a/Projects/Devault/Sources/Composition/Dependencies/LiveServices.swift b/Projects/Devault/Sources/Composition/Dependencies/LiveServices.swift new file mode 100644 index 00000000..aa648a74 --- /dev/null +++ b/Projects/Devault/Sources/Composition/Dependencies/LiveServices.swift @@ -0,0 +1,12 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVData +import DVDomain +import DVPresentation + +/// Composition Root 전체에서 공유하는 Service 인스턴스. 알림 문구는 DVData가 접근 못 하는 로컬라이제이션 카탈로그 때문에 `SecurityNotification.moduleContent(for:)`(DVPresentation)에서 만들어 주입한다. +enum LiveServices { + static let securityNotification: any SecurityNotificationService = SecurityNotificationServiceImpl( + makeContent: SecurityNotification.moduleContent(for:) + ) +} diff --git a/Projects/Devault/Sources/Composition/Dependencies/LiveUseCases.swift b/Projects/Devault/Sources/Composition/Dependencies/LiveUseCases.swift index 4e480b0f..94cdbc34 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/LiveUseCases.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/LiveUseCases.swift @@ -10,6 +10,6 @@ import DVDomain enum LiveUseCases { static let authenticate: any AuthenticateUseCase = AuthenticateUseCaseImpl( authenticationService: LocalUserAuthenticationServiceImpl(), - notificationService: SecurityNotificationServiceImpl() + notificationService: LiveServices.securityNotification ) } diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift index 6922fd7b..8159c0d3 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift @@ -11,15 +11,10 @@ extension SecretClient: @retroactive DependencyKey { let secretRepo = LiveRepositories.secret let projectRepo = LiveRepositories.project let cryptoService: any SecretCryptoService = SecretCryptoServiceImpl() - let authenticationService: any UserAuthenticationService = LocalUserAuthenticationServiceImpl() - let notificationService: any SecurityNotificationService = SecurityNotificationServiceImpl() - let authenticateUseCase: any AuthenticateUseCase = AuthenticateUseCaseImpl( - authenticationService: authenticationService, - notificationService: notificationService - ) + let authenticateUseCase: any AuthenticateUseCase = LiveUseCases.authenticate let expiryUseCase: any ScheduleSecretExpiryNotificationsUseCase = ScheduleSecretExpiryNotificationsUseCaseImpl( repository: secretRepo, - notificationService: notificationService + notificationService: LiveServices.securityNotification ) let fetchSecretUseCase: any FetchSecretUseCase = FetchSecretUseCaseImpl( diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretManagementClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SecretManagementClient+Live.swift index 84e9c0ac..25b684b5 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SecretManagementClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SecretManagementClient+Live.swift @@ -13,10 +13,9 @@ extension SecretManagementClient: @retroactive DependencyKey { repository: LiveRepositories.secret, cryptoService: cryptoService ) - let notificationService: any SecurityNotificationService = SecurityNotificationServiceImpl() let expiryUseCase: any ScheduleSecretExpiryNotificationsUseCase = ScheduleSecretExpiryNotificationsUseCaseImpl( repository: LiveRepositories.secret, - notificationService: notificationService + notificationService: LiveServices.securityNotification ) return SecretManagementClient( createSecret: { draft, payload, projectIds in diff --git a/Projects/Devault/Sources/Composition/LiveSettingsRepository.swift b/Projects/Devault/Sources/Composition/LiveSettingsRepository.swift deleted file mode 100644 index 9f60b7dc..00000000 --- a/Projects/Devault/Sources/Composition/LiveSettingsRepository.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright © 2026 Devault. All rights reserved - -import DVData -import DVDomain - -/// Composition Root에서 공유하는 SettingsRepository 단일 인스턴스. -enum LiveSettingsRepository { - static let shared: any SettingsRepository = SettingsRepositoryImpl() -} diff --git a/Projects/Devault/Sources/Composition/LiveStorage.swift b/Projects/Devault/Sources/Composition/LiveStorage.swift index e7477d42..658c6868 100644 --- a/Projects/Devault/Sources/Composition/LiveStorage.swift +++ b/Projects/Devault/Sources/Composition/LiveStorage.swift @@ -12,7 +12,7 @@ enum LiveStorage { // TODO: Settings 화면에서 토글을 지원하려면 재시작 안내를 띄우거나, ModelContainer를 런타임에 재생성하는 hot-swap이 필요하다. static let shared: LocalStorage = { do { - return try LocalStorage.makeDefault(iCloudSyncEnabled: LiveSettingsRepository.shared.isICloudSyncEnabled()) + return try LocalStorage.makeDefault(iCloudSyncEnabled: LiveRepositories.settings.isICloudSyncEnabled()) } catch { logger.critical("LocalStorage 초기화 실패: \(error, privacy: .public)") fatalError("LocalStorage 초기화 실패 — 앱을 재시작하거나 데이터를 복원하세요.\n\(error)") From add38f1e6cfa35ed2b4fd9a8c024bd587ad13bdc Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 16:42:03 +0900 Subject: [PATCH 04/16] =?UTF-8?q?[#89]=20feat:=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20iCloud=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iCloud 동기화 실패 시 상태별 안내와 재시도/설정 열기/계속하기 버튼이 있는 alert 추가 - syncing(로딩 스피너) 단계를 syncEnabled(체크마크 성공 화면)으로 교체, Lottie 의존성 제거 - 시스템 설정 iCloud 패널 딥링크 URL을 최신 pane identifier로 수정 (구버전 URL은 일반 패널로 폴백됨) - 동기화 성공 후 0.5초 지연 뒤 성공 화면 전환, 그 동안 버튼 비활성 상태 유지 - 성공 화면 노출 시간을 1.2초에서 2초로 연장 - 온보딩 문자열을 String.module로 전환, 장식용 앱 아이콘에 accessibilityHidden 추가 --- .../Dependencies/OnboardingClient.swift | 6 +- .../Onboarding/OnboardingFeature.swift | 77 +++++++++---- .../Features/Onboarding/OnboardingView.swift | 62 +++++----- .../Onboarding/OnboardingFeatureTests.swift | 109 ++++++++++++++++-- .../Dependencies/OnboardingClient+Live.swift | 11 +- 5 files changed, 199 insertions(+), 66 deletions(-) diff --git a/Projects/DVPresentation/Sources/Dependencies/OnboardingClient.swift b/Projects/DVPresentation/Sources/Dependencies/OnboardingClient.swift index d47153d5..7ef3f043 100644 --- a/Projects/DVPresentation/Sources/Dependencies/OnboardingClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/OnboardingClient.swift @@ -15,6 +15,9 @@ public struct OnboardingClient: Sendable { /// iCloud 계정 상태를 확인하고, 사용 가능하면 동기화 사용 설정을 저장한다. public var enableICloudSync: @Sendable () async -> ICloudAccountStatus = { .couldNotDetermine } + + /// 시스템 설정 앱의 iCloud 패널을 연다. iCloud 계정 미로그인/제한 상태 알럿에서 사용. + public var openICloudSystemSettings: @Sendable () async -> Void } extension OnboardingClient: TestDependencyKey { @@ -22,7 +25,8 @@ extension OnboardingClient: TestDependencyKey { public static let previewValue = OnboardingClient( enableTouchID: { }, - enableICloudSync: { .available } + enableICloudSync: { .available }, + openICloudSystemSettings: { } ) } diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift index 6916c752..8aaca53f 100644 --- a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift @@ -16,7 +16,7 @@ public struct OnboardingFeature { case welcome case security case icloudSync - case syncing + case syncEnabled } // MARK: - State @@ -24,6 +24,7 @@ public struct OnboardingFeature { @ObservableState public struct State: Equatable { public var step: Step = .welcome + public var isEnablingSync = false @Presents var alert: AlertState? public init(step: Step = .welcome) { @@ -32,10 +33,10 @@ public struct OnboardingFeature { var currentStepIndex: Int { switch step { - case .welcome: return 0 - case .security: return 1 - case .icloudSync: return 2 - case .syncing: return 2 + case .welcome: return 0 + case .security: return 1 + case .icloudSync: return 2 + case .syncEnabled: return 2 } } } @@ -56,7 +57,7 @@ public struct OnboardingFeature { case touchIDAuthSucceeded case touchIDAuthFailed(UserAuthenticationError) case iCloudSyncStatusResponse(ICloudAccountStatus) - case syncingCompleted + case enableSyncCompleted // MARK: - Child @@ -70,12 +71,17 @@ public struct OnboardingFeature { case completed } - public enum Alert: Equatable {} + public enum Alert: Equatable { + case retry + case continueWithoutSync + case openSystemSettings + } } // MARK: - Dependencies @Dependency(\.onboardingClient) var onboardingClient + @Dependency(\.continuousClock) var clock // MARK: - Init @@ -114,7 +120,7 @@ public struct OnboardingFeature { return .send(.delegate(.completed)) case .didTapEnableSync: - state.step = .syncing + state.isEnablingSync = true return .run { send in let status = await onboardingClient.enableICloudSync() await send(.iCloudSyncStatusResponse(status)) @@ -122,15 +128,31 @@ public struct OnboardingFeature { case .iCloudSyncStatusResponse(let status): guard status == .available else { - state.step = .icloudSync + state.isEnablingSync = false state.alert = makeICloudSyncUnavailableAlert(status) return .none } - return .send(.syncingCompleted) + return .run { send in + try? await clock.sleep(for: .seconds(0.5)) + await send(.enableSyncCompleted) + } + + case .enableSyncCompleted: + state.step = .syncEnabled + return .run { send in + try? await clock.sleep(for: .seconds(2)) + await send(.delegate(.completed)) + } + + case .alert(.presented(.retry)): + return .send(.didTapEnableSync) - case .syncingCompleted: + case .alert(.presented(.continueWithoutSync)): return .send(.delegate(.completed)) + case .alert(.presented(.openSystemSettings)): + return .run { _ in await onboardingClient.openICloudSystemSettings() } + case .alert: return .none @@ -147,35 +169,44 @@ public struct OnboardingFeature { private extension OnboardingFeature { func makeTouchIDFailedAlert(_ error: UserAuthenticationError) -> AlertState { - makeUserAuthenticationFailedAlert(title: "인증하지 못했어요", error: error) + makeUserAuthenticationFailedAlert(title: String.module("Authentication failed"), error: error) } - // 지금은 상태별로 알럿 문구만 구분한다. 동기화 진행 상태 표시, 재시도 유도 등 온보딩 iCloud UX 전반은 - // 별도 이슈에서 다룰 예정이다. + /// 상태별로 문구를 구분하고, 재시도 가능한 상태에는 재시도 버튼을, 계정 문제로 인한 상태에는 + /// 시스템 설정 앱을 바로 여는 버튼을 추가한다. 어떤 상태든 iCloud 없이 계속 진행할 수 있다. func makeICloudSyncUnavailableAlert(_ status: ICloudAccountStatus) -> AlertState { let message: String switch status { case .available: assertionFailure("iCloudSyncStatusResponse가 이미 .available을 걸러내므로 도달 불가") - message = "다시 시도해주세요." + message = String.module("Please try again.") case .noAccount: - message = "설정 앱에서 iCloud 로그인 후 다시 시도해주세요." + message = String.module("Sign in to iCloud in System Settings, then try again.") case .restricted: - message = "기기의 iCloud 사용 제한 설정을 확인해주세요." + message = String.module("Check your device's iCloud usage restrictions.") case .temporarilyUnavailable: - message = "잠시 후 다시 시도해주세요." + message = String.module("Please try again in a moment.") case .networkUnavailable: - message = "네트워크 연결을 확인하고 다시 시도해주세요." + message = String.module("Check your network connection and try again.") case .configurationUnavailable: // 앱 배포 설정(컨테이너 식별자, entitlement) 문제라 사용자가 재시도해도 해결되지 않음. - message = "iCloud 동기화를 지금 사용할 수 없어요. 나중에 다시 시도해주세요." + message = String.module("iCloud sync isn't available right now. Please try again later.") case .couldNotDetermine: - message = "iCloud 상태를 확인하지 못했어요. 잠시 후 다시 시도해주세요." + message = String.module("Couldn't determine iCloud status. Please try again in a moment.") } + let canRetry = status != .configurationUnavailable + let canOpenSettings = status == .noAccount || status == .restricted return AlertState { - TextState("iCloud 동기화를 사용할 수 없어요") + TextState(String.module("iCloud sync isn't available")) } actions: { - ButtonState(role: .cancel) { TextState("확인") } + if canRetry { + ButtonState(action: .retry) { TextState(String.module("Try Again")) } + } + if canOpenSettings { + ButtonState(action: .openSystemSettings) { TextState(String.module("Open System Settings")) } + } + ButtonState(action: .continueWithoutSync) { TextState(String.module("Continue Without iCloud")) } + ButtonState(role: .cancel) { TextState(String.module("OK")) } } message: { TextState(message) } diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift index f459f84d..d96387ad 100644 --- a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift @@ -4,7 +4,6 @@ import SwiftUI import ComposableArchitecture import DVDesign -import Lottie // MARK: - OnboardingView @@ -43,8 +42,8 @@ extension OnboardingView { switch store.step { case .welcome: welcomeView case .security: securityView - case .icloudSync: icloudSyncView - case .syncing: syncingView + case .icloudSync: icloudSyncView + case .syncEnabled: syncEnabledView } } @@ -53,7 +52,7 @@ extension OnboardingView { private var welcomeView: some View { VStack(spacing: 40) { appIconWithLogoView - DVButton(titleText: "Start", style: .primary) { + DVButton(titleText: String.module("Start"), style: .primary) { store.send(.didTapStart) } } @@ -63,12 +62,12 @@ extension OnboardingView { private var securityView: some View { VStack(spacing: 56) { - appIconWithTextView("Your secrets are protected with Touch ID") + appIconWithTextView(String.module("Your secrets are protected with Touch ID")) VStack(spacing: 12) { - DVButton(titleText: "Enable Touch ID", style: .primary) { + DVButton(titleText: String.module("Enable Touch ID"), style: .primary) { store.send(.didTapEnableTouchID) } - Text("If Touch ID is unavailable,\nsystem password will be used") + Text(.module("If Touch ID is unavailable,\nsystem password will be used")) .dvFont(.captionMDRegular) .foregroundStyle(Color.dv(.gray900)) .multilineTextAlignment(.center) @@ -80,35 +79,41 @@ extension OnboardingView { private var icloudSyncView: some View { VStack(spacing: 20) { - appIconWithTextView("Sync your secrets with iCloud?") - Text("Access your secrets on all your\nApple devices, securely encrypted.") + appIconWithTextView(String.module("Sync your secrets with iCloud?")) + Text(.module("Access your secrets on all your\nApple devices, securely encrypted.")) .dvFont(.bodyMD) .foregroundStyle(Color.dv(.gray900)) .multilineTextAlignment(.center) .padding(.bottom, 12) - HStack(spacing: 16) { - DVButton(titleText: "Not Now", style: .primarySmall) { - store.send(.didTapNotNow) - } - DVButton(titleText: "Enable Sync", style: .primarySmall) { - store.send(.didTapEnableSync) + VStack(spacing: 10) { + HStack(spacing: 16) { + DVButton(titleText: String.module("Not Now"), style: .primarySmall) { + store.send(.didTapNotNow) + } + DVButton(titleText: String.module("Enable Sync"), style: .primarySmall) { + store.send(.didTapEnableSync) + } } + .disabled(store.isEnablingSync) + Text(.module("You can change this anytime in Settings")) + .dvFont(.captionMDRegular) + .foregroundStyle(Color.dv(.gray600)) } } } - // MARK: 1.3 Syncing + // MARK: 1.3 Sync Enabled - private var syncingView: some View { - VStack(spacing: 24) { - appIconWithTextView("Syncing...") - LottieView { - try await DotLottieFile.named("progress", bundle: DVDesignResources.bundle) - } - .playing(loopMode: .loop) - .frame(width: 124, height: 62) - Text("This may take a moment...") - .dvFont(.bodyMD) + private var syncEnabledView: some View { + VStack(spacing: 20) { + Image(systemName: "checkmark.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 64) + .foregroundStyle(Color.dv(.vaultGreen)) + .accessibilityHidden(true) + Text(.module("iCloud Sync Enabled!")) + .dvFont(.headingXL) .foregroundStyle(Color.dv(.gray900)) } } @@ -120,6 +125,7 @@ extension OnboardingView { .resizable() .scaledToFit() .frame(width: 80) + .accessibilityHidden(true) } private var appIconWithLogoView: some View { @@ -177,10 +183,10 @@ extension OnboardingView { .frame(width: 540, height: 400) } -#Preview("Syncing") { +#Preview("Sync Enabled") { OnboardingView( store: Store( - initialState: OnboardingFeature.State(step: .syncing) + initialState: OnboardingFeature.State(step: .syncEnabled) ) { OnboardingFeature() } diff --git a/Projects/DVPresentation/Tests/Onboarding/OnboardingFeatureTests.swift b/Projects/DVPresentation/Tests/Onboarding/OnboardingFeatureTests.swift index dd964d7b..4ee78384 100644 --- a/Projects/DVPresentation/Tests/Onboarding/OnboardingFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Onboarding/OnboardingFeatureTests.swift @@ -36,32 +36,38 @@ struct OnboardingFeatureTests { await store.send(.didTapEnableTouchID) await store.receive(.touchIDAuthFailed(.failed)) { $0.alert = AlertState { - TextState("인증하지 못했어요") + TextState("Authentication failed") } actions: { - ButtonState(role: .cancel) { TextState("확인") } + ButtonState(role: .cancel) { TextState("OK") } } message: { - TextState("다시 시도해주세요.") + TextState("Please try again.") } } } - @Test("didTapEnableSync는 iCloud 계정을 사용할 수 있으면 온보딩을 완료한다") + @Test("didTapEnableSync는 iCloud 계정을 사용할 수 있으면 성공 스텝을 거쳐 온보딩을 완료한다") func enableSyncSucceeds() async { + let clock = TestClock() let store = TestStore(initialState: OnboardingFeature.State(step: .icloudSync)) { OnboardingFeature() } withDependencies: { + $0.continuousClock = clock $0.onboardingClient.enableICloudSync = { .available } } await store.send(.didTapEnableSync) { - $0.step = .syncing + $0.isEnablingSync = true } await store.receive(.iCloudSyncStatusResponse(.available)) - await store.receive(.syncingCompleted) + await clock.advance(by: .seconds(0.5)) + await store.receive(.enableSyncCompleted) { + $0.step = .syncEnabled + } + await clock.advance(by: .seconds(2)) await store.receive(.delegate(.completed)) } - @Test("didTapEnableSync는 iCloud 계정이 없으면 alert를 띄우고 되돌아간다") + @Test("didTapEnableSync는 iCloud 계정이 없으면 재시도·계속·설정 열기 버튼이 있는 alert를 띄우고 되돌아간다") func enableSyncShowsAlertWhenNoAccount() async { let store = TestStore(initialState: OnboardingFeature.State(step: .icloudSync)) { OnboardingFeature() @@ -70,20 +76,86 @@ struct OnboardingFeatureTests { } await store.send(.didTapEnableSync) { - $0.step = .syncing + $0.isEnablingSync = true } await store.receive(.iCloudSyncStatusResponse(.noAccount)) { - $0.step = .icloudSync + $0.isEnablingSync = false $0.alert = AlertState { - TextState("iCloud 동기화를 사용할 수 없어요") + TextState("iCloud sync isn't available") } actions: { - ButtonState(role: .cancel) { TextState("확인") } + ButtonState(action: .retry) { TextState("Try Again") } + ButtonState(action: .openSystemSettings) { TextState("Open System Settings") } + ButtonState(action: .continueWithoutSync) { TextState("Continue Without iCloud") } + ButtonState(role: .cancel) { TextState("OK") } } message: { - TextState("설정 앱에서 iCloud 로그인 후 다시 시도해주세요.") + TextState("Sign in to iCloud in System Settings, then try again.") } } } + @Test("iCloud 계정 문제 alert에서 재시도를 누르면 다시 동기화를 시도한다") + func retryButtonRetriesSync() async { + let store = TestStore(initialState: OnboardingFeature.State(step: .icloudSync)) { + OnboardingFeature() + } withDependencies: { + $0.onboardingClient.enableICloudSync = { .noAccount } + } + + await store.send(.didTapEnableSync) { $0.isEnablingSync = true } + await store.receive(.iCloudSyncStatusResponse(.noAccount)) { + $0.isEnablingSync = false + $0.alert = makeNoAccountAlert() + } + await store.send(.alert(.presented(.retry))) { + $0.alert = nil + } + await store.receive(.didTapEnableSync) { $0.isEnablingSync = true } + await store.receive(.iCloudSyncStatusResponse(.noAccount)) { + $0.isEnablingSync = false + $0.alert = makeNoAccountAlert() + } + } + + @Test("iCloud 계정 문제 alert에서 계속을 누르면 온보딩을 완료 처리한다") + func continueWithoutSyncCompletesOnboarding() async { + let store = TestStore(initialState: OnboardingFeature.State(step: .icloudSync)) { + OnboardingFeature() + } withDependencies: { + $0.onboardingClient.enableICloudSync = { .noAccount } + } + + await store.send(.didTapEnableSync) { $0.isEnablingSync = true } + await store.receive(.iCloudSyncStatusResponse(.noAccount)) { + $0.isEnablingSync = false + $0.alert = makeNoAccountAlert() + } + await store.send(.alert(.presented(.continueWithoutSync))) { + $0.alert = nil + } + await store.receive(.delegate(.completed)) + } + + @Test("iCloud 계정 문제 alert에서 설정 열기를 누르면 시스템 설정을 연다") + func openSystemSettingsOpensSystemPreferences() async { + let opened = LockIsolated(false) + let store = TestStore(initialState: OnboardingFeature.State(step: .icloudSync)) { + OnboardingFeature() + } withDependencies: { + $0.onboardingClient.enableICloudSync = { .noAccount } + $0.onboardingClient.openICloudSystemSettings = { opened.setValue(true) } + } + + await store.send(.didTapEnableSync) { $0.isEnablingSync = true } + await store.receive(.iCloudSyncStatusResponse(.noAccount)) { + $0.isEnablingSync = false + $0.alert = makeNoAccountAlert() + } + await store.send(.alert(.presented(.openSystemSettings))) { + $0.alert = nil + } + #expect(opened.value == true) + } + @Test("didTapNotNow는 온보딩을 완료 처리한다") func notNowCompletesOnboarding() async { let store = TestStore(initialState: OnboardingFeature.State(step: .icloudSync)) { @@ -94,3 +166,16 @@ struct OnboardingFeatureTests { await store.receive(.delegate(.completed)) } } + +private func makeNoAccountAlert() -> AlertState { + AlertState { + TextState("iCloud sync isn't available") + } actions: { + ButtonState(action: .retry) { TextState("Try Again") } + ButtonState(action: .openSystemSettings) { TextState("Open System Settings") } + ButtonState(action: .continueWithoutSync) { TextState("Continue Without iCloud") } + ButtonState(role: .cancel) { TextState("OK") } + } message: { + TextState("Sign in to iCloud in System Settings, then try again.") + } +} diff --git a/Projects/Devault/Sources/Composition/Dependencies/OnboardingClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/OnboardingClient+Live.swift index 46237aeb..c4ad7cf1 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/OnboardingClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/OnboardingClient+Live.swift @@ -1,5 +1,7 @@ // Copyright © 2026 Devault. All rights reserved +import AppKit + import ComposableArchitecture import DVCore import DVPresentation @@ -11,8 +13,9 @@ extension OnboardingClient: @retroactive DependencyKey { let accountService: any ICloudAccountService = CloudKitAccountServiceImpl( containerIdentifier: ICloudContainer.identifier ) - let repository: any SettingsRepository = SettingsRepositoryImpl() - let iCloudSyncSettings: any ICloudSyncSettingsUseCase = ICloudSyncSettingsUseCaseImpl(repository: repository) + let iCloudSyncSettings: any ICloudSyncSettingsUseCase = ICloudSyncSettingsUseCaseImpl( + repository: LiveRepositories.settings + ) return OnboardingClient( enableTouchID: { @@ -24,6 +27,10 @@ extension OnboardingClient: @retroactive DependencyKey { iCloudSyncSettings.setEnabled(true) } return status + }, + openICloudSystemSettings: { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preferences.AppleIDPrefPane?icloud") else { return } + NSWorkspace.shared.open(url) } ) }() From 484de1e66402eb78cfeee79ef3f0635d2d153dd0 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 16:43:05 +0900 Subject: [PATCH 05/16] =?UTF-8?q?[#89]=20feat:=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=EB=A1=9C=EC=BB=AC=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=A0=9C=EC=9D=B4=EC=85=98=20=EB=B0=8F=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 필터 타이틀, 프로젝트 섹션 문자열을 String.module로 전환 - 아이콘 전용 버튼에 accessibilityLabel 추가 - 카테고리·프로젝트 행에 accessibilityElement(children: .combine) 적용 --- .../Features/Sidebar/SidebarFeature.swift | 10 ++++----- .../Features/Sidebar/SidebarView.swift | 21 +++++++++++-------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index 1a4f29c5..189d8d3a 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -15,11 +15,11 @@ public enum SidebarFilter: Equatable, CaseIterable, Hashable, Sendable { var title: String { switch self { - case .all: "All" - case .starred: "Star" - case .notice: "Notice" - case .expired: "Expired" - case .deleted: "Deleted" + case .all: .module("All") + case .starred: .module("Star") + case .notice: .module("Notice") + case .expired: .module("Expired") + case .deleted: .module("Deleted") } } diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index a08fb33f..d11bb63f 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -69,6 +69,7 @@ extension SidebarView { store.send(.didSelect(.filter(.all))) } .frame(height: 72) + .accessibilityElement(children: .combine) LazyVGrid( columns: [GridItem(.flexible()), GridItem(.flexible())], @@ -85,6 +86,7 @@ extension SidebarView { store.send(.didSelect(.filter(filter))) } .frame(height: 72) + .accessibilityElement(children: .combine) } } } @@ -113,7 +115,7 @@ extension SidebarView { case .loaded: projectList case .failed: - Text("Failed to load") + Text(.module("Failed to load")) .dvFont(.bodyMD) .foregroundStyle(Color.dv(.danger)) .frame(maxWidth: .infinity) @@ -124,18 +126,18 @@ extension SidebarView { private var projectSectionHeader: some View { HStack(spacing: 11) { - Text("Project") + Text(.module("Project")) .dvFont(.captionMDSemibold) .foregroundStyle(Color.dv(.vaultGreen)) Spacer() projectHeaderButton(icon: "plus.circle") { store.send(.didTapAddProject) } - .accessibilityLabel("Add Project") + .accessibilityLabel(String.module("Add Project")) projectHeaderButton( icon: store.isProjectSectionExpanded ? "chevron.down" : "chevron.up" ) { store.send(.didToggleProjectSection) } - .accessibilityLabel(store.isProjectSectionExpanded ? "Collapse Projects" : "Expand Projects") + .accessibilityLabel(store.isProjectSectionExpanded ? String.module("Collapse Projects") : String.module("Expand Projects")) } } @@ -155,12 +157,13 @@ extension SidebarView { ) { ForEach(store.projects) { project in projectRow(project) + .accessibilityElement(children: .combine) .tag(project.id) .contextMenu { - Button("Rename") { store.send(.didTapRename(id: project.id)) } - Button("New Secret") { store.send(.didTapAddButton) } + Button(String.module("Rename")) { store.send(.didTapRename(id: project.id)) } + Button(String.module("New Secret")) { store.send(.didTapAddButton) } Divider() - Button("Delete Project", role: .destructive) { + Button(String.module("Delete Project"), role: .destructive) { store.send(.didTapDelete(id: project.id)) } } @@ -200,10 +203,10 @@ extension SidebarView { circleIconButton(icon: "gearshape") { openWindow(id: "settings") } - .accessibilityLabel("Settings") + .accessibilityLabel(String.module("Settings")) Spacer() circleIconButton(icon: "plus") { store.send(.didTapAddButton) } - .accessibilityLabel("Add Secret") + .accessibilityLabel(String.module("Add Secret")) } } From 11fc497e15567a4826ebb0cd824c5262a2f5023d Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 16:43:25 +0900 Subject: [PATCH 06/16] =?UTF-8?q?[#89]=20fix:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20alert=20=EB=AC=B8=EA=B5=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=EB=9D=BC=EC=9D=B4=EC=A0=9C=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 하드코딩된 한글 alert 문구를 String.module 기반 영문 키로 전환 - 관련 테스트 기대값 갱신 --- .../Sources/Support/UserAuthenticationAlert.swift | 8 ++++---- Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Projects/DVPresentation/Sources/Support/UserAuthenticationAlert.swift b/Projects/DVPresentation/Sources/Support/UserAuthenticationAlert.swift index 06d17000..d5c78b6f 100644 --- a/Projects/DVPresentation/Sources/Support/UserAuthenticationAlert.swift +++ b/Projects/DVPresentation/Sources/Support/UserAuthenticationAlert.swift @@ -11,16 +11,16 @@ func makeUserAuthenticationFailedAlert( let message: String switch error { case .unavailable: - message = "시스템 설정에서 로그인 암호가 설정되어 있는지 확인해주세요." + message = String.module("Check that a login password is set in System Settings.") case .cancelled: - message = "다시 시도해주세요." + message = String.module("Please try again.") case .failed: - message = "다시 시도해주세요." + message = String.module("Please try again.") } return AlertState { TextState(title) } actions: { - ButtonState(role: .cancel) { TextState("확인") } + ButtonState(role: .cancel) { TextState(String.module("OK")) } } message: { TextState(message) } diff --git a/Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift b/Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift index d0583578..2c71cdb2 100644 --- a/Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift @@ -37,9 +37,9 @@ struct LockFeatureTests { $0.alert = AlertState { TextState("잠금을 해제하지 못했어요") } actions: { - ButtonState(role: .cancel) { TextState("확인") } + ButtonState(role: .cancel) { TextState("OK") } } message: { - TextState("다시 시도해주세요.") + TextState("Please try again.") } } } From 2eb961f1e9c6761cfe147b8e5df31a1e698bf468 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 16:43:43 +0900 Subject: [PATCH 07/16] =?UTF-8?q?[#89]=20fix:=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=EB=B2=84=ED=8A=BC=EC=9D=B4=20=EA=B3=BC=EB=8F=84?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EB=B0=9D=EA=B2=8C=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - primary/primarySmall 스타일 비활성 시 opacity 0.5 적용 - secondary 계열은 기존 색상 기반 로직 유지 (중복 적용 방지) --- .../SampleApp/Sources/DVButtonPreviewView.swift | 1 - Projects/DVDesign/Sources/Components/DVButton.swift | 12 +++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Projects/DVDesign/SampleApp/Sources/DVButtonPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVButtonPreviewView.swift index e01d02c7..e0e44055 100644 --- a/Projects/DVDesign/SampleApp/Sources/DVButtonPreviewView.swift +++ b/Projects/DVDesign/SampleApp/Sources/DVButtonPreviewView.swift @@ -54,7 +54,6 @@ extension DVButtonPreviewView { DVButton(titleText: "Cancel", style: .secondary) {} .disabled(true) } - .frame(width: 74) } private var buttonPair: some View { diff --git a/Projects/DVDesign/Sources/Components/DVButton.swift b/Projects/DVDesign/Sources/Components/DVButton.swift index b9567b1a..837a57f7 100644 --- a/Projects/DVDesign/Sources/Components/DVButton.swift +++ b/Projects/DVDesign/Sources/Components/DVButton.swift @@ -110,6 +110,16 @@ private struct DVButtonStyle: ButtonStyle { .foregroundStyle(foregroundColor) .background(backgroundColor(isPressed: configuration.isPressed)) .clipShape(RoundedRectangle(cornerRadius: style.cornerRadius)) + .opacity(!isEnabled && dimsWhenDisabled ? 0.5 : 1) + } + + /// primary/primarySmall은 배경이 항상 vaultGreen이라 비활성 시 opacity로 톤을 낮춘다. + /// secondary/secondaryProminent는 이미 자체 비활성 색상(gray400 등)이 있어 중복 적용하지 않는다. + private var dimsWhenDisabled: Bool { + switch style { + case .primary, .primarySmall: return true + case .secondary, .secondaryProminent: return false + } } private var foregroundColor: Color { @@ -126,7 +136,7 @@ private struct DVButtonStyle: ButtonStyle { private func backgroundColor(isPressed: Bool) -> Color { switch style { case .primary, .primarySmall: - if !isEnabled { return Color.dv(.vaultGreenTint) } + if !isEnabled { return Color.dv(.vaultGreen) } if isPressed || isHovered { return Color.dv(.vaultGreenDark) } return Color.dv(.vaultGreen) case .secondary: From 5f81c34b55377afe0304058c1b69b7a5c2285d54 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 16:43:56 +0900 Subject: [PATCH 08/16] =?UTF-8?q?[#89]=20chore:=20String=20Catalog=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EC=84=A4=EC=A0=95=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DVPresentation 타겟에 SWIFT_EMIT_LOC_STRINGS, LOCALIZATION_PREFERS_STRING_CATALOGS 활성화 - String.module / LocalizedStringResource.module을 public으로 노출 --- Projects/DVPresentation/Project.swift | 7 ++++++- .../Localization/LocalizedStringResource+Module.swift | 2 +- .../Sources/Localization/String+Module.swift | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Projects/DVPresentation/Project.swift b/Projects/DVPresentation/Project.swift index 7ef86892..d8cc8945 100644 --- a/Projects/DVPresentation/Project.swift +++ b/Projects/DVPresentation/Project.swift @@ -18,7 +18,12 @@ let project = Project.project( // 3rd-party dependency .tca(), .lottie(), - ] + ], + // 기본값(NO)이면 String.module(_:) 콜사이트가 Localizable.xcstrings로 추출되지 않는다. + settings: .settings(base: [ + "SWIFT_EMIT_LOC_STRINGS": "YES", + "LOCALIZATION_PREFERS_STRING_CATALOGS": "YES", + ]) ), .tests( name: "DVPresentationTests", diff --git a/Projects/DVPresentation/Sources/Localization/LocalizedStringResource+Module.swift b/Projects/DVPresentation/Sources/Localization/LocalizedStringResource+Module.swift index 20aed0b5..882611f0 100644 --- a/Projects/DVPresentation/Sources/Localization/LocalizedStringResource+Module.swift +++ b/Projects/DVPresentation/Sources/Localization/LocalizedStringResource+Module.swift @@ -2,7 +2,7 @@ import Foundation -extension LocalizedStringResource { +public extension LocalizedStringResource { /// DVPresentation 모듈 번들의 `Localizable.xcstrings`에서 문자열을 룩업. static func module(_ key: String.LocalizationValue) -> LocalizedStringResource { LocalizedStringResource(key, bundle: .atURL(Bundle.module.bundleURL)) diff --git a/Projects/DVPresentation/Sources/Localization/String+Module.swift b/Projects/DVPresentation/Sources/Localization/String+Module.swift index ce5a3021..48c0feae 100644 --- a/Projects/DVPresentation/Sources/Localization/String+Module.swift +++ b/Projects/DVPresentation/Sources/Localization/String+Module.swift @@ -2,7 +2,7 @@ import Foundation -extension String { +public extension String { /// DVPresentation 모듈 번들의 `Localizable.xcstrings` 룩업 후 `String`으로 반환. /// SwiftUI `Text` 자동 로컬라이즈가 안 되는 지점(파라미터가 `String` 타입인 서브뷰 등)에서 /// `label: .module("Foo")` 형태로 축약 호출. From afca29372b480b936425411679fdce3cdf6d55f1 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 16:44:15 +0900 Subject: [PATCH 09/16] =?UTF-8?q?[#89]=20chore:=20Localizable.xcstrings=20?= =?UTF-8?q?=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위 변경들로 추가된 String.module 콜사이트를 카탈로그에 반영 (번역은 추후 진행) --- .../Resources/Localizable.xcstrings | 568 +++++++++++++++++- 1 file changed, 562 insertions(+), 6 deletions(-) diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 67421143..5915f2c8 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -1,52 +1,608 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, + "'%@' 프로젝트를 삭제할까요?" : { + + }, + "A database error occurred while loading projects. The project list may be incomplete." : { + + }, + "A database error occurred while saving the secret. Please try again." : { + "comment" : "Text displayed in an alert when saving a secret fails due to a database error.", + "isCommentAutoGenerated" : true + }, + "A saved secret will expire in %lld days." : { + "comment" : "The number of days before the secret expires is dynamically inserted here.", + "isCommentAutoGenerated" : true + }, + "A secret is expiring soon" : { + "comment" : "Title of a notification when a saved secret is expiring soon.", + "isCommentAutoGenerated" : true + }, + "A value was copied %lld times in a short period" : { + "comment" : "A description of an abnormal access notification. The argument is the number of times a value was copied.", + "isCommentAutoGenerated" : true + }, + "Abnormal access detected" : { + "comment" : "Title of the notification when an abnormal access is detected.", + "isCommentAutoGenerated" : true + }, + "Access Token" : { + + }, "Access your secrets on all your\nApple devices, securely encrypted." : { + }, + "Add new project" : { + "comment" : "Label for the \"Add new project\" button in the \"Project\" field of the CreateSecret form.", + "isCommentAutoGenerated" : true + }, + "Add Project" : { + + }, + "Add Secret" : { + "comment" : "Text for the button that allows the user to add a new secret.", + "isCommentAutoGenerated" : true + }, + "Add to Project" : { + "comment" : "A context menu item that adds a secret to a project.", + "isCommentAutoGenerated" : true + }, + "All" : { + + }, + "An unexpected error occurred. Please try again." : { + + }, + "API Key" : { + "comment" : "Label for the \"API Key\" option in the \"Create Secret\" screen.", + "isCommentAutoGenerated" : true + }, + "API Keys/Token" : { + "comment" : "Title of the \"Create Secret\" screen's top label.", + "isCommentAutoGenerated" : true + }, + "API Webhook Secret" : { + "comment" : "Label for the \"API Webhook Secret\" option in the \"Create Secret\" screen.", + "isCommentAutoGenerated" : true + }, + "Authentication failed" : { + "comment" : "Title of an alert displayed when touch ID authentication fails.", + "isCommentAutoGenerated" : true + }, + "Authentication failed %lld times in a short period" : { + "comment" : "A description of an abnormal access notification. The argument is the number of times authentication failed in a short period.", + "isCommentAutoGenerated" : true + }, + "Authentication required" : { + + }, + "Authority / Scope" : { + "comment" : "Label for the \"Authority / Scope\" field in the API Key Token form section.", + "isCommentAutoGenerated" : true + }, + "Auto-detected: %@" : { + "comment" : "A hint indicating that a field value was automatically detected. The text inside the parentheses will be replaced with the actual detected value.", + "isCommentAutoGenerated" : true + }, + "Cancel" : { + "comment" : "The label of a button that cancels an action.", + "isCommentAutoGenerated" : true + }, + "Certificate" : { + "comment" : "Label for the certificate field in the SSL/TLS certificate form section.", + "isCommentAutoGenerated" : true + }, + "Certificate Chain" : { + + }, + "Certification JSON" : { + "comment" : "Label for the text field that allows the user to input the JSON string for the service account credential.", + "isCommentAutoGenerated" : true + }, + "Check that a login password is set in System Settings." : { + "comment" : "Message in an alert that instructs the user to check their System Settings for a login password.", + "isCommentAutoGenerated" : true + }, + "Check your device's iCloud usage restrictions." : { + + }, + "Check your network connection and try again." : { + "comment" : "Text displayed in an alert when the user's network connection is unavailable.", + "isCommentAutoGenerated" : true + }, + "Client ID" : { + "comment" : "Label for the \"Client ID\" field in the \"OAuth Client\" form section.", + "isCommentAutoGenerated" : true + }, + "Client Secret" : { + + }, + "Clipboard cleared" : { + "comment" : "Text displayed in a notification when the user clears the clipboard.", + "isCommentAutoGenerated" : true + }, + "Collapse Projects" : { + "comment" : "Accessibility label for a button that collapses the section displaying the user's projects.", + "isCommentAutoGenerated" : true + }, + "Continue Without iCloud" : { + "comment" : "Text for a button that allows the user to continue using the app without syncing with iCloud.", + "isCommentAutoGenerated" : true + }, + "Couldn't determine iCloud status. Please try again in a moment." : { + "comment" : "An alert message that appears when the app cannot determine the iCloud account status.", + "isCommentAutoGenerated" : true + }, + "Create" : { + }, "Create Project" : { + }, + "Custom" : { + + }, + "Database" : { + }, "De" : { + }, + "Delete" : { + + }, + "Delete Forever" : { + "comment" : "The text of a button to permanently delete an item.", + "isCommentAutoGenerated" : true + }, + "Delete Project" : { + + }, + "Deleted" : { + + }, + "Development" : { + + }, + "Discard" : { + + }, + "Discard changes?" : { + + }, + "Done" : { + + }, + "e.g -----BEGIN CERTIFICATE-----" : { + + }, + "e.g -----BEGIN OPENSSH PRIVATE KEY-----" : { + + }, + "e.g -----BEGIN PRIVATE KEY-----" : { + "comment" : "Placeholder text for the private key field in the SSL/TLS certificate form section.", + "isCommentAutoGenerated" : true + }, + "e.g {\"type\": \"service_account\", ...}" : { + + }, + "e.g abc123secret" : { + "comment" : "Placeholder text for the \"Client Secret\" text field in the \"OAuth Client\" form section.", + "isCommentAutoGenerated" : true + }, + "e.g certbot renew" : { + + }, + "e.g custom-secret-value" : { + "comment" : "Placeholder text for the value field in the custom secret form.", + "isCommentAutoGenerated" : true + }, + "e.g DeVault" : { + "comment" : "Placeholder text for the Name field in the CreateSecret form.", + "isCommentAutoGenerated" : true + }, + "e.g example.com" : { + + }, + "e.g FOO=bar\\nBAZ=qux" : { + + }, + "e.g ghp_1234567890" : { + + }, + "e.g https://app.example/oauth/callback" : { + + }, + "e.g https://example.com" : { + + }, + "e.g my-app-client" : { + + }, + "e.g ORD-2026-0001" : { + + }, + "e.g organization-admin" : { + "comment" : "Placeholder text for the \"Authority / Scope\" text field in the `ServiceAccountSectionView`.", + "isCommentAutoGenerated" : true + }, + "e.g postgres://user:pass@host:5432/db" : { + "comment" : "Placeholder text for the \"Link String\" field in the \"Database\" form section.", + "isCommentAutoGenerated" : true + }, + "e.g read:user, write:issue" : { + + }, + "e.g repo:read, user:email" : { + + }, + "e.g root" : { + + }, + "e.g ssh-rsa AAAA..." : { + + }, + "e.g support@example.com" : { + "comment" : "Placeholder text for the \"Support Email\" field in the \"License Key\" form section.", + "isCommentAutoGenerated" : true + }, + "e.g XXXXX-XXXXX-XXXXX-XXXXX" : { + "comment" : "Placeholder text for a license key field.", + "isCommentAutoGenerated" : true + }, + "e.g. github.com" : { + "comment" : "Label text for the Services field in the CreateSecret form.", + "isCommentAutoGenerated" : true + }, + "Enable Sync" : { + + }, + "Enable Touch ID" : { + + }, + "Encryption unavailable" : { + + }, + "Enterprise" : { + "comment" : "Description of a license tier when the user is an enterprise user.", + "isCommentAutoGenerated" : true + }, + "Environment" : { + + }, + "EnvSet" : { + + }, + "envSet List" : { + "comment" : "Label for the \"envSet List\" field in the form.", + "isCommentAutoGenerated" : true + }, + "Etc" : { + + }, + "Expand Projects" : { + "comment" : "Accessibility label for the button that expands the list of projects.", + "isCommentAutoGenerated" : true + }, + "Expire Date" : { + "comment" : "Label for the \"Expire Date\" field in the \"Create Secret\" form.", + "isCommentAutoGenerated" : true + }, + "Expired" : { + "comment" : "Title of the sidebar filter option for expired items.", + "isCommentAutoGenerated" : true }, "Failed to load" : { - "comment" : "A message displayed when loading projects fails.", + "comment" : "Text displayed in the sidebar when loading the list of projects fails.", "isCommentAutoGenerated" : true + }, + "Failed to load projects" : { + }, "Failed to load the list" : { "comment" : "A title for an alert that indicates that the list of secrets failed to load.", "isCommentAutoGenerated" : true }, + "Failed to reveal secret" : { + + }, + "Host" : { + + }, + "iCloud Sync Enabled!" : { + + }, + "iCloud sync isn't available" : { + "comment" : "Title of an alert when iCloud sync is unavailable.", + "isCommentAutoGenerated" : true + }, + "iCloud sync isn't available right now. Please try again later." : { + "comment" : "Text displayed in an alert when the iCloud sync status cannot be determined.", + "isCommentAutoGenerated" : true + }, "If Touch ID is unavailable,\nsystem password will be used" : { + "comment" : "Additional text below the button to inform the user that if Touch ID is unavailable, the system password will be used.", + "isCommentAutoGenerated" : true + }, + "Individual" : { + "comment" : "Description of a license tier when the user is an individual.", + "isCommentAutoGenerated" : true + }, + "Keep editing" : { }, - "LOGO" : { + "License Key" : { }, - "No secret selected" : { - "comment" : "A message displayed when a secret is not selected.", + "Link String" : { + + }, + "Lock App" : { + "comment" : "Accessibility label for the lock button in the MainView.", "isCommentAutoGenerated" : true + }, + "Memo" : { + + }, + "Name" : { + + }, + "New Secret" : { + + }, + "No expiration" : { + + }, + "No projects yet" : { + + }, + "No secret selected" : { + }, "No secrets" : { "comment" : "A message displayed when there are no secrets.", "isCommentAutoGenerated" : true + }, + "Not Now" : { + + }, + "Notice" : { + + }, + "OAuth" : { + + }, + "OAuth Client" : { + "comment" : "Title of the \"OAuth Client\" option in the \"Create Secret\" screen.", + "isCommentAutoGenerated" : true + }, + "OK" : { + + }, + "Open System Settings" : { + + }, + "optional" : { + + }, + "Order Number" : { + "comment" : "Label for the \"Order Number\" field in the \"License Key\" form section.", + "isCommentAutoGenerated" : true + }, + "PassPhrase" : { + "comment" : "Label for the passphrase field in the SSH key form section.", + "isCommentAutoGenerated" : true + }, + "Please authenticate to save the secret." : { + + }, + "Please authenticate to view the secret." : { + "comment" : "Alert message when authentication is required to view a secret.", + "isCommentAutoGenerated" : true + }, + "Please try again in a moment." : { + + }, + "Please try again." : { + "comment" : "Text for an alert that appears when the user can try again after a failure.", + "isCommentAutoGenerated" : true + }, + "Private Key" : { + + }, + "Production" : { + }, "Project" : { }, "Project Name" : { + }, + "Public Key" : { + + }, + "Recover" : { + + }, + "Redirect URL" : { + + }, + "Rename" : { + + }, + "Renew Command" : { + + }, + "Required" : { + "comment" : "Validation error message when a required field is empty.", + "isCommentAutoGenerated" : true + }, + "Save" : { + "comment" : "The label of a button that saves the current input.", + "isCommentAutoGenerated" : true + }, + "Save failed" : { + + }, + "Scope" : { + }, "Select Project" : { }, - "This may take a moment..." : { + "Service Account" : { + + }, + "Services" : { + "comment" : "Label text for the \"Services\" field in the CreateSecret form.", + "isCommentAutoGenerated" : true + }, + "Settings" : { + + }, + "Sign in to iCloud in System Settings, then try again." : { + + }, + "SSH & Credentials" : { + + }, + "SSH Key" : { + + }, + "SSL Required" : { + + }, + "SSL/TLS Certificate" : { + + }, + "Staging" : { + + }, + "Star" : { + "comment" : "System symbol name for a starred item in the sidebar.", + "isCommentAutoGenerated" : true + }, + "Start" : { + + }, + "Support Email" : { + + }, + "Sync your secrets with iCloud?" : { + + }, + "Team" : { + + }, + "The copied value was cleared after being on the clipboard for over %lld seconds." : { + "comment" : "The body of the notification when a value on the clipboard is cleared after being on the clipboard for more than a few seconds. The argument is the number of seconds.", + "isCommentAutoGenerated" : true + }, + "The project list couldn't be loaded. Please try again later." : { + + }, + "The secret could not be decrypted. Check that your device passcode is enabled." : { + + }, + "The secret could not be saved because encryption is unavailable. Check that your device passcode is enabled." : { + + }, + "Try Again" : { + "comment" : "Label for a button that triggers the action of retrying an operation.", + "isCommentAutoGenerated" : true + }, + "Type" : { + "comment" : "Label text for the \"Type\" field in the CreateSecret form.", + "isCommentAutoGenerated" : true + }, + "Username" : { + + }, + "Value" : { }, "Vault" : { + }, + "Website" : { + + }, + "You can change this anytime in Settings" : { + + }, + "Your secrets are protected with Touch ID" : { + + }, + "Your unsaved changes will be lost." : { + "comment" : "Message displayed in an alert when the user confirms discarding their unsaved changes in a feature.", + "isCommentAutoGenerated" : true + }, + "다른 이름을 입력해주세요." : { + + }, + "다른 프로젝트 이름을 입력해주세요." : { + + }, + "삭제" : { + + }, + "선택됨: %@" : { + + }, + "아직 선택 안 함" : { + + }, + "이름을 변경하지 못했어요" : { + "comment" : "Text for an alert that appears when a secret is not renamed.", + "isCommentAutoGenerated" : true + }, + "이름을 입력해주세요" : { + "comment" : "Title of an alert that appears when a user tries to rename a project with an empty name.", + "isCommentAutoGenerated" : true + }, + "이미 사용 중인 이름이에요" : { + + }, + "이미 사용 중인 프로젝트 이름이에요" : { + "comment" : "Text in an alert displayed when a user tries to create a project with a name that is already taken.", + "isCommentAutoGenerated" : true + }, + "작업을 완료하지 못했어요" : { + + }, + "잠시 후 다시 시도해주세요." : { + "comment" : "Message shown when linking a secret to a project fails.", + "isCommentAutoGenerated" : true + }, + "취소" : { + + }, + "프로젝트 이름은 비워둘 수 없어요." : { + "comment" : "Message shown in an alert when a user tries to rename a project with an empty name.", + "isCommentAutoGenerated" : true + }, + "프로젝트를 만들지 못했어요" : { + "comment" : "Text in an alert displayed when they try to create a new project but fails.", + "isCommentAutoGenerated" : true + }, + "프로젝트를 삭제하지 못했어요" : { + "comment" : "Alert message when deleting a project fails.", + "isCommentAutoGenerated" : true + }, + "프로젝트에 속한 Secret과의 연결이 해제됩니다. Secret 자체는 삭제되지 않습니다." : { + + }, + "프로젝트에 추가하지 못했어요" : { + + }, + "확인" : { + } }, "version" : "1.1" -} +} \ No newline at end of file From cd2f522feeeb6591bb33120ca201c8abd88a0a71 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 17:15:30 +0900 Subject: [PATCH 10/16] =?UTF-8?q?[#89]=20fix:=20Localizable.xcstrings=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/Localizable.xcstrings | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 5915f2c8..255c148e 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -47,6 +47,10 @@ "comment" : "Text for the button that allows the user to add a new secret.", "isCommentAutoGenerated" : true }, + "Add to favorites" : { + "comment" : "Accessibility label for the \"Add to favorites\" button in the secret detail header.", + "isCommentAutoGenerated" : true + }, "Add to Project" : { "comment" : "A context menu item that adds a secret to a project.", "isCommentAutoGenerated" : true @@ -77,6 +81,10 @@ "comment" : "A description of an abnormal access notification. The argument is the number of times authentication failed in a short period.", "isCommentAutoGenerated" : true }, + "Authentication is required to reveal this secret." : { + "comment" : "Alert 제목으로 사용.", + "isCommentAutoGenerated" : true + }, "Authentication required" : { }, @@ -151,6 +159,10 @@ }, "De" : { + }, + "Decrypting secret…" : { + "comment" : "Text shown while decrypting a secret.", + "isCommentAutoGenerated" : true }, "Delete" : { @@ -208,8 +220,9 @@ "e.g example.com" : { }, - "e.g FOO=bar\\nBAZ=qux" : { - + "e.g FOO=bar" : { + "comment" : "Placeholder text for the \"envSet List\" text field in the form.", + "isCommentAutoGenerated" : true }, "e.g ghp_1234567890" : { @@ -258,6 +271,10 @@ "comment" : "Label text for the Services field in the CreateSecret form.", "isCommentAutoGenerated" : true }, + "Edit" : { + "comment" : "Accessibility label for the \"Edit\" button in the secret detail header.", + "isCommentAutoGenerated" : true + }, "Enable Sync" : { }, @@ -296,6 +313,10 @@ "comment" : "Title of the sidebar filter option for expired items.", "isCommentAutoGenerated" : true }, + "Failed to delete secret" : { + "comment" : "Text displayed in an alert when a secret deletion fails.", + "isCommentAutoGenerated" : true + }, "Failed to load" : { "comment" : "Text displayed in the sidebar when loading the list of projects fails.", "isCommentAutoGenerated" : true @@ -309,6 +330,10 @@ }, "Failed to reveal secret" : { + }, + "Failed to update favorite" : { + "comment" : "Text displayed in an alert when updating a favorite status of a secret fails.", + "isCommentAutoGenerated" : true }, "Host" : { @@ -347,6 +372,10 @@ }, "Memo" : { + }, + "Move to trash?" : { + "comment" : "Alert title when confirming to delete a secret.", + "isCommentAutoGenerated" : true }, "Name" : { @@ -369,6 +398,10 @@ }, "Not Now" : { + }, + "Not Required" : { + "comment" : "Text indicating that SSL is not required for this database.", + "isCommentAutoGenerated" : true }, "Notice" : { @@ -431,6 +464,10 @@ }, "Redirect URL" : { + }, + "Remove from favorites" : { + "comment" : "Label for a button that removes a secret from the user's favorites.", + "isCommentAutoGenerated" : true }, "Rename" : { @@ -442,6 +479,10 @@ "comment" : "Validation error message when a required field is empty.", "isCommentAutoGenerated" : true }, + "Retry" : { + "comment" : "Button title that triggers the retry action for revealing a secret.", + "isCommentAutoGenerated" : true + }, "Save" : { "comment" : "The label of a button that saves the current input.", "isCommentAutoGenerated" : true @@ -464,6 +505,10 @@ }, "Settings" : { + }, + "Share" : { + "comment" : "Label for the \"Share\" action in the secret detail view.", + "isCommentAutoGenerated" : true }, "Sign in to iCloud in System Settings, then try again." : { @@ -511,6 +556,10 @@ }, "The secret could not be saved because encryption is unavailable. Check that your device passcode is enabled." : { + }, + "This secret could not be decrypted." : { + "comment" : "Alert message when a secret cannot be decrypted.", + "isCommentAutoGenerated" : true }, "Try Again" : { "comment" : "Label for a button that triggers the action of retrying an operation.", @@ -534,6 +583,10 @@ }, "You can change this anytime in Settings" : { + }, + "You can restore it later from Trash." : { + "comment" : "Text displayed in an alert when a user confirms deleting a secret and learning more about restoring it later.", + "isCommentAutoGenerated" : true }, "Your secrets are protected with Touch ID" : { From 5d5b83346e60b7e02d0b6995a6f19d655bcd1ee5 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 17:36:32 +0900 Subject: [PATCH 11/16] =?UTF-8?q?[#89]=20fix:=20iCloud=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=84=B1=EA=B3=B5=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "iCloud Sync Enabled!"만으로는 계정 확인과 실제 데이터 동기화 완료가 헷갈릴 수 있어 설명 캡션 추가 - iCloud 동기화 버튼 영역 spacing 조정 --- .../Sources/Features/Onboarding/OnboardingView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift index d96387ad..823407b9 100644 --- a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift @@ -85,7 +85,7 @@ extension OnboardingView { .foregroundStyle(Color.dv(.gray900)) .multilineTextAlignment(.center) .padding(.bottom, 12) - VStack(spacing: 10) { + VStack(spacing: 15) { HStack(spacing: 16) { DVButton(titleText: String.module("Not Now"), style: .primarySmall) { store.send(.didTapNotNow) @@ -115,6 +115,10 @@ extension OnboardingView { Text(.module("iCloud Sync Enabled!")) .dvFont(.headingXL) .foregroundStyle(Color.dv(.gray900)) + Text(.module("Your secrets will sync automatically when a connection is available.")) + .dvFont(.captionMDRegular) + .foregroundStyle(Color.dv(.gray600)) + .multilineTextAlignment(.center) } } From a80be9dd360cc3ef20993990651fb95f87ac3cbc Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 17:42:50 +0900 Subject: [PATCH 12/16] =?UTF-8?q?[#89]=20fix:=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20alert=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EB=A1=9C=EC=BB=AC=EB=9D=BC=EC=9D=B4=EC=A0=9C?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 하드코딩된 한글 제목("잠금을 해제하지 못했어요")을 String.module 기반으로 전환 - 메시지/버튼은 이미 로컬라이제이션됐는데 제목만 빠져 있던 문제 수정 --- Projects/DVPresentation/Sources/Features/Lock/LockFeature.swift | 2 +- Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Lock/LockFeature.swift b/Projects/DVPresentation/Sources/Features/Lock/LockFeature.swift index f9b156e2..517cec57 100644 --- a/Projects/DVPresentation/Sources/Features/Lock/LockFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Lock/LockFeature.swift @@ -95,6 +95,6 @@ public struct LockFeature { private extension LockFeature { func makeUnlockFailedAlert(_ error: UserAuthenticationError) -> AlertState { - makeUserAuthenticationFailedAlert(title: "잠금을 해제하지 못했어요", error: error) + makeUserAuthenticationFailedAlert(title: String.module("Unlock failed"), error: error) } } diff --git a/Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift b/Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift index 2c71cdb2..41f2555d 100644 --- a/Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Lock/LockFeatureTests.swift @@ -35,7 +35,7 @@ struct LockFeatureTests { await store.send(.didTapUnlock) await store.receive(.unlockAuthFailed(.failed)) { $0.alert = AlertState { - TextState("잠금을 해제하지 못했어요") + TextState("Unlock failed") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { From 8c88ac4a6de3ce79febf5562777b6cc72a09a80e Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 17:42:57 +0900 Subject: [PATCH 13/16] =?UTF-8?q?[#89]=20refactor:=20iCloud=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20alert=EC=9D=98=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84/=EC=84=A4=EC=A0=95=20=EC=97=B4=EA=B8=B0=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EB=A5=BC=20exhaustive=20switch=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - canRetry/canOpenSettings를 status와의 개별 !=/== 비교 대신 message와 같은 switch 안 튜플로 통합 - ICloudAccountStatus에 케이스가 추가되면 세 값 모두 컴파일러가 재검토를 강제하도록 함 --- .../Onboarding/OnboardingFeature.swift | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift index 8aaca53f..be3b40d9 100644 --- a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift @@ -175,27 +175,27 @@ private extension OnboardingFeature { /// 상태별로 문구를 구분하고, 재시도 가능한 상태에는 재시도 버튼을, 계정 문제로 인한 상태에는 /// 시스템 설정 앱을 바로 여는 버튼을 추가한다. 어떤 상태든 iCloud 없이 계속 진행할 수 있다. func makeICloudSyncUnavailableAlert(_ status: ICloudAccountStatus) -> AlertState { - let message: String - switch status { - case .available: + // 세 값을 하나의 exhaustive switch로 묶어야 새 ICloudAccountStatus 케이스 추가 시 컴파일러가 전부 재검토를 강제한다. + if status == .available { assertionFailure("iCloudSyncStatusResponse가 이미 .available을 걸러내므로 도달 불가") - message = String.module("Please try again.") + } + let (message, canRetry, canOpenSettings): (String, Bool, Bool) = switch status { + case .available: + (String.module("Please try again."), true, false) case .noAccount: - message = String.module("Sign in to iCloud in System Settings, then try again.") + (String.module("Sign in to iCloud in System Settings, then try again."), true, true) case .restricted: - message = String.module("Check your device's iCloud usage restrictions.") + (String.module("Check your device's iCloud usage restrictions."), true, true) case .temporarilyUnavailable: - message = String.module("Please try again in a moment.") + (String.module("Please try again in a moment."), true, false) case .networkUnavailable: - message = String.module("Check your network connection and try again.") + (String.module("Check your network connection and try again."), true, false) case .configurationUnavailable: // 앱 배포 설정(컨테이너 식별자, entitlement) 문제라 사용자가 재시도해도 해결되지 않음. - message = String.module("iCloud sync isn't available right now. Please try again later.") + (String.module("iCloud sync isn't available right now. Please try again later."), false, false) case .couldNotDetermine: - message = String.module("Couldn't determine iCloud status. Please try again in a moment.") + (String.module("Couldn't determine iCloud status. Please try again in a moment."), true, false) } - let canRetry = status != .configurationUnavailable - let canOpenSettings = status == .noAccount || status == .restricted return AlertState { TextState(String.module("iCloud sync isn't available")) } actions: { From 5a695d90443fd18c7cbd3e0d87e25fbfbd9c2e2a Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 17:51:02 +0900 Subject: [PATCH 14/16] =?UTF-8?q?[#89]=20refactor:=20public=20extension=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EC=84=A0=EC=96=B8=EB=B3=84=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A0=9C=EC=96=B4=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extension 블록 전체를 public으로 여는 대신 각 static func에 public을 직접 명시 - 나중에 이 extension에 멤버가 추가돼도 기본값이 internal이라 공개 API가 실수로 넓어지지 않음 --- .../Sources/Localization/LocalizedStringResource+Module.swift | 4 ++-- .../Sources/Localization/SecurityNotification+Module.swift | 4 ++-- .../DVPresentation/Sources/Localization/String+Module.swift | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Projects/DVPresentation/Sources/Localization/LocalizedStringResource+Module.swift b/Projects/DVPresentation/Sources/Localization/LocalizedStringResource+Module.swift index 882611f0..8f091926 100644 --- a/Projects/DVPresentation/Sources/Localization/LocalizedStringResource+Module.swift +++ b/Projects/DVPresentation/Sources/Localization/LocalizedStringResource+Module.swift @@ -2,9 +2,9 @@ import Foundation -public extension LocalizedStringResource { +extension LocalizedStringResource { /// DVPresentation 모듈 번들의 `Localizable.xcstrings`에서 문자열을 룩업. - static func module(_ key: String.LocalizationValue) -> LocalizedStringResource { + public static func module(_ key: String.LocalizationValue) -> LocalizedStringResource { LocalizedStringResource(key, bundle: .atURL(Bundle.module.bundleURL)) } } diff --git a/Projects/DVPresentation/Sources/Localization/SecurityNotification+Module.swift b/Projects/DVPresentation/Sources/Localization/SecurityNotification+Module.swift index 64296496..78f77c21 100644 --- a/Projects/DVPresentation/Sources/Localization/SecurityNotification+Module.swift +++ b/Projects/DVPresentation/Sources/Localization/SecurityNotification+Module.swift @@ -2,10 +2,10 @@ import DVDomain -public extension SecurityNotification { +extension SecurityNotification { /// `SecurityNotificationServiceImpl`(DVData)에 주입하는 알림 문구 팩토리. DVData가 접근 못 하는 로컬라이제이션 카탈로그를 이 모듈에서 대신 룩업한다. @Sendable - static func moduleContent(for notification: SecurityNotification) -> (title: String, body: String) { + public static func moduleContent(for notification: SecurityNotification) -> (title: String, body: String) { switch notification { case .abnormalAccess(let kind, let threshold): let body: String diff --git a/Projects/DVPresentation/Sources/Localization/String+Module.swift b/Projects/DVPresentation/Sources/Localization/String+Module.swift index 48c0feae..c53b3075 100644 --- a/Projects/DVPresentation/Sources/Localization/String+Module.swift +++ b/Projects/DVPresentation/Sources/Localization/String+Module.swift @@ -2,11 +2,11 @@ import Foundation -public extension String { +extension String { /// DVPresentation 모듈 번들의 `Localizable.xcstrings` 룩업 후 `String`으로 반환. /// SwiftUI `Text` 자동 로컬라이즈가 안 되는 지점(파라미터가 `String` 타입인 서브뷰 등)에서 /// `label: .module("Foo")` 형태로 축약 호출. - static func module(_ key: String.LocalizationValue) -> String { + public static func module(_ key: String.LocalizationValue) -> String { String(localized: LocalizedStringResource.module(key)) } } From 23593e02ed6dd211adb7624b6ec2bacb3d20735f Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 17:57:39 +0900 Subject: [PATCH 15/16] =?UTF-8?q?[#89]=20fix:=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20=EC=A0=84=20=EB=A7=8C=EB=A3=8C=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=EA=B0=80=20ModelContainer?= =?UTF-8?q?=EB=A5=BC=20=EC=A1=B0=EA=B8=B0=20=EA=B3=A0=EC=A0=95=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncExpiryNotifications가 LiveRepositories.secret을 처음 resolve시켜 ModelContainer를 그 순간의 iCloud 설정으로 영구 고정하는데, 이게 온보딩에서 iCloud 동기화를 켜기 전에 실행돼 "동기화 켬"이 실제로는 반영 안 되는 문제가 있었음 - 온보딩 완료 전엔 Secret이 없어 동기화할 것도 없으므로, hasCompletedOnboarding이 true일 때만 syncExpiryNotifications를 호출하도록 gate - 앱 실행 중 설정 변경(Settings 토글) 시 런타임 재생성은 범위 밖 — 후속 이슈에서 다룰 예정 --- .../Sources/Features/AppFeature.swift | 11 +++--- .../Tests/AppFeatureTests.swift | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/AppFeature.swift b/Projects/DVPresentation/Sources/Features/AppFeature.swift index 931b887a..3ca190d5 100644 --- a/Projects/DVPresentation/Sources/Features/AppFeature.swift +++ b/Projects/DVPresentation/Sources/Features/AppFeature.swift @@ -65,18 +65,21 @@ public struct AppFeature { state.locked = nil state.main = nil - if appLaunchClient.hasCompletedOnboarding() { + let hasCompletedOnboarding = appLaunchClient.hasCompletedOnboarding() + if hasCompletedOnboarding { state.locked = .init() } else { state.onboarding = .init() } + + // syncExpiryNotifications가 LiveRepositories.secret을 처음 건드려 ModelContainer를 그 순간의 iCloud 설정으로 고정시키므로, 아직 Secret이 없는 온보딩 전에는 건너뛴다. return .merge( .run { _ in _ = await appLaunchClient.requestNotificationAuthorization() }, - .run { _ in - await appLaunchClient.syncExpiryNotifications() - } + hasCompletedOnboarding + ? .run { _ in await appLaunchClient.syncExpiryNotifications() } + : .none ) #if DEBUG diff --git a/Projects/DVPresentation/Tests/AppFeatureTests.swift b/Projects/DVPresentation/Tests/AppFeatureTests.swift index 3984f04e..e84b9943 100644 --- a/Projects/DVPresentation/Tests/AppFeatureTests.swift +++ b/Projects/DVPresentation/Tests/AppFeatureTests.swift @@ -25,6 +25,41 @@ struct AppFeatureTests { } } + @Test("task는 온보딩을 완료했으면 만료 알림을 동기화한다") + func taskSyncsExpiryNotificationsWhenOnboardingCompleted() async { + let synced = LockIsolated(false) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.appLaunchClient.hasCompletedOnboarding = { true } + $0.appLaunchClient.requestNotificationAuthorization = { true } + $0.appLaunchClient.syncExpiryNotifications = { synced.setValue(true) } + } + + await store.send(.task) { + $0.locked = .init() + } + + #expect(synced.value) + } + + // syncExpiryNotifications가 LiveRepositories.secret을 처음 건드려 ModelContainer를 그 순간의 iCloud 설정으로 고정시키므로, 아직 Secret이 없는 온보딩 전에는 건너뛴다. + @Test("task는 온보딩 전이면 만료 알림 동기화를 건너뛴다") + func taskSkipsExpiryNotificationsBeforeOnboarding() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.appLaunchClient.hasCompletedOnboarding = { false } + $0.appLaunchClient.requestNotificationAuthorization = { true } + // syncExpiryNotifications를 오버라이드하지 않는다 — 호출되면 @DependencyClient의 + // unimplemented 클로저가 테스트를 실패시킨다. + } + + await store.send(.task) { + $0.onboarding = .init() + } + } + @Test("main의 lockRequested delegate는 main을 지우고 locked를 새로 연다") func lockRequestedLocksApp() async { var initial = AppFeature.State() From 065ede0cb51468ac220f379fbdf01979a4c80a63 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Fri, 14 Aug 2026 18:02:48 +0900 Subject: [PATCH 16/16] =?UTF-8?q?[#89]=20fix:=20Localizable.xcstrings=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVPresentation/Resources/Localizable.xcstrings | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 255c148e..7d6511bc 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -568,6 +568,9 @@ "Type" : { "comment" : "Label text for the \"Type\" field in the CreateSecret form.", "isCommentAutoGenerated" : true + }, + "Unlock failed" : { + }, "Username" : { @@ -590,6 +593,10 @@ }, "Your secrets are protected with Touch ID" : { + }, + "Your secrets will sync automatically when a connection is available." : { + "comment" : "Text displayed in the \"Sync Enabled\" view, explaining that the user's secrets will sync automatically when a connection is available.", + "isCommentAutoGenerated" : true }, "Your unsaved changes will be lost." : { "comment" : "Message displayed in an alert when the user confirms discarding their unsaved changes in a feature.",