diff --git a/Devault.xcworkspace/contents.xcworkspacedata b/Devault.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..0aca565f --- /dev/null +++ b/Devault.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Devault.xcworkspace/xcshareddata/xcschemes/Devault-Workspace.xcscheme b/Devault.xcworkspace/xcshareddata/xcschemes/Devault-Workspace.xcscheme new file mode 100644 index 00000000..4c1f5480 --- /dev/null +++ b/Devault.xcworkspace/xcshareddata/xcschemes/Devault-Workspace.xcscheme @@ -0,0 +1,1030 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Devault.xcworkspace/xcshareddata/xcschemes/Generate Project.xcscheme b/Devault.xcworkspace/xcshareddata/xcschemes/Generate Project.xcscheme new file mode 100644 index 00000000..f7e294e2 --- /dev/null +++ b/Devault.xcworkspace/xcshareddata/xcschemes/Generate Project.xcscheme @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + diff --git a/Projects/DVCore/DVCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Projects/DVCore/DVCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Projects/DVCore/DVCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Projects/DVCore/DVCore.xcodeproj/xcshareddata/xcschemes/DVCore.xcscheme b/Projects/DVCore/DVCore.xcodeproj/xcshareddata/xcschemes/DVCore.xcscheme new file mode 100644 index 00000000..8e1cb61a --- /dev/null +++ b/Projects/DVCore/DVCore.xcodeproj/xcshareddata/xcschemes/DVCore.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVData/DVData.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Projects/DVData/DVData.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Projects/DVData/DVData.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Projects/DVData/DVData.xcodeproj/xcshareddata/xcschemes/DVData.xcscheme b/Projects/DVData/DVData.xcodeproj/xcshareddata/xcschemes/DVData.xcscheme new file mode 100644 index 00000000..3e9c8aca --- /dev/null +++ b/Projects/DVData/DVData.xcodeproj/xcshareddata/xcschemes/DVData.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift index d777a60f..27a481d6 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift @@ -12,6 +12,8 @@ public struct SettingsRepositoryImpl: SettingsRepository, @unchecked Sendable { // TODO: SecretEnvironment를 DVDomain 공용 타입으로 이동하면 `.dev.rawValue`로 대체한다. private enum DefaultValue { static let environment = "dev" + // AppAppearance.system.rawValue와 동일. 기본은 macOS 시스템 설정을 따른다. + static let appearance = "system" } public init( @@ -24,6 +26,7 @@ public struct SettingsRepositoryImpl: SettingsRepository, @unchecked Sendable { // 아직 사용자가 저장한 값이 없을 때 사용할 기본값 defaults.register(defaults: [ UserDefaultsKey.defaultEnvironment.rawValue: DefaultValue.environment, + UserDefaultsKey.appearance.rawValue: DefaultValue.appearance, UserDefaultsKey.isRequireAuthOnLaunchEnabled.rawValue: true, UserDefaultsKey.isRequireAuthToCopyEnabled.rawValue: true, UserDefaultsKey.isAutoLockEnabled.rawValue: true, @@ -86,6 +89,31 @@ public struct SettingsRepositoryImpl: SettingsRepository, @unchecked Sendable { defaults.set(rawValue, forKey: .defaultEnvironment) } + public func appearance() -> String { + defaults.string(forKey: .appearance) ?? DefaultValue.appearance + } + + public func setAppearance(_ rawValue: String) { + defaults.set(rawValue, forKey: .appearance) + } + + public func appearanceStream() -> AsyncStream { + AsyncStream { continuation in + let observer = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: defaults, + queue: nil + ) { _ in + continuation.yield(appearance()) + } + + continuation.yield(appearance()) + continuation.onTermination = { _ in + NotificationCenter.default.removeObserver(observer) + } + } + } + // MARK: - Security public func isRequireAuthOnLaunchEnabled() -> Bool { diff --git a/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift b/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift index 046407db..85967e66 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift @@ -12,6 +12,7 @@ enum UserDefaultsKey: String { // General case isLaunchAtLoginEnabled case defaultEnvironment + case appearance // Security case isRequireAuthOnLaunchEnabled diff --git a/Projects/DVData/Sources/ServiceImpl/Notification/SecurityNotificationServiceImpl.swift b/Projects/DVData/Sources/ServiceImpl/Notification/SecurityNotificationServiceImpl.swift index 73ad28e5..224db982 100644 --- a/Projects/DVData/Sources/ServiceImpl/Notification/SecurityNotificationServiceImpl.swift +++ b/Projects/DVData/Sources/ServiceImpl/Notification/SecurityNotificationServiceImpl.swift @@ -65,6 +65,10 @@ public struct SecurityNotificationServiceImpl: SecurityNotificationService { public func cancel(identifiers: [String]) async { center.removePendingNotificationRequests(withIdentifiers: identifiers) } + + public func pendingIdentifiers() async -> [String] { + await center.pendingNotificationRequests().map(\.identifier) + } } // MARK: - Private diff --git a/Projects/DVDesign/DVDesign.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Projects/DVDesign/DVDesign.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Projects/DVDesign/DVDesign.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Projects/DVDesign/DVDesign.xcodeproj/xcshareddata/xcschemes/DVDesign.xcscheme b/Projects/DVDesign/DVDesign.xcodeproj/xcshareddata/xcschemes/DVDesign.xcscheme new file mode 100644 index 00000000..85e3afd7 --- /dev/null +++ b/Projects/DVDesign/DVDesign.xcodeproj/xcshareddata/xcschemes/DVDesign.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVDesign/DVDesign.xcodeproj/xcshareddata/xcschemes/DVDesignSampleApp.xcscheme b/Projects/DVDesign/DVDesign.xcodeproj/xcshareddata/xcschemes/DVDesignSampleApp.xcscheme new file mode 100644 index 00000000..4ce3d96d --- /dev/null +++ b/Projects/DVDesign/DVDesign.xcodeproj/xcshareddata/xcschemes/DVDesignSampleApp.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVDesign/DVDesign.xcodeproj/xcshareddata/xcschemes/DVDesign_DVDesign.xcscheme b/Projects/DVDesign/DVDesign.xcodeproj/xcshareddata/xcschemes/DVDesign_DVDesign.xcscheme new file mode 100644 index 00000000..f99e746c --- /dev/null +++ b/Projects/DVDesign/DVDesign.xcodeproj/xcshareddata/xcschemes/DVDesign_DVDesign.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift index de08c504..2cd2ba83 100644 --- a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift +++ b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift @@ -70,8 +70,7 @@ extension DVVaultContainerPreviewView { service: vaults[index].3, typeIcon: vaults[index].4 ? placeholderTypeIcon : nil, trailingIcon: vaults[index].2, - trailingIconTooltip: vaults[index].2 != nil ? "Expires soon" : nil, - isSelected: selectedIndex == index + trailingIconTooltip: vaults[index].2 != nil ? "Expires soon" : nil ) .tag(index) .contextMenu { diff --git a/Projects/DVDesign/Sources/Components/DVCategory.swift b/Projects/DVDesign/Sources/Components/DVCategory.swift index 51dcfc42..6ac0c8ba 100644 --- a/Projects/DVDesign/Sources/Components/DVCategory.swift +++ b/Projects/DVDesign/Sources/Components/DVCategory.swift @@ -72,6 +72,7 @@ extension DVCategory { .dvFont(.headingLG) .foregroundStyle(isSelected ? Color.dv(.white) : iconColor) .frame(width: 24, height: 24) + .accessibilityHidden(true) } private var titleLabel: some View { diff --git a/Projects/DVDesign/Sources/Components/DVCheckBox.swift b/Projects/DVDesign/Sources/Components/DVCheckBox.swift index 0aed50be..d76fe7f9 100644 --- a/Projects/DVDesign/Sources/Components/DVCheckBox.swift +++ b/Projects/DVDesign/Sources/Components/DVCheckBox.swift @@ -59,6 +59,7 @@ public struct DVCheckBox: View { } .buttonStyle(.plain) .onHover { isHovered = $0 } + .accessibilityAddTraits(isChecked ? .isSelected : []) } else { checkboxShape } diff --git a/Projects/DVDesign/Sources/Components/DVDropdown.swift b/Projects/DVDesign/Sources/Components/DVDropdown.swift index fc5daf98..80666b05 100644 --- a/Projects/DVDesign/Sources/Components/DVDropdown.swift +++ b/Projects/DVDesign/Sources/Components/DVDropdown.swift @@ -80,6 +80,7 @@ extension DVDropdown { .font(DVFont.captionMDSemibold.font) .foregroundStyle(Color.black.opacity(0.85)) .frame(width: 24, height: 24) + .accessibilityHidden(true) } } diff --git a/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift b/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift index 7063a4d0..800f6b5e 100644 --- a/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift +++ b/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift @@ -369,6 +369,7 @@ private struct SearchHeaderView: View { Image(systemName: "magnifyingglass") .font(DVFont.bodyMD.font) .foregroundStyle(Color.dv(.gray600)) + .accessibilityHidden(true) TextField(placeholder, text: $text) .textFieldStyle(.plain) .dvFont(.bodyLG) @@ -470,6 +471,7 @@ private struct RowView: View { HStack(spacing: DropdownMetrics.rowContentSpacing) { DVCheckBox(isChecked: isSelected) { action() } .allowsHitTesting(false) + .accessibilityHidden(true) Text(highlighted) .dvFont(.bodyLG) .foregroundStyle(Color.dv(.gray900)) @@ -483,6 +485,7 @@ private struct RowView: View { .background(isSelected ? Color.dv(.vaultGreenTint) : Color.clear) } .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) } /// 라벨을 AttributedString으로 렌더링. `query`와 정확히 일치하는(대소문자 무관) diff --git a/Projects/DVDesign/Sources/Components/DVProjectContainer.swift b/Projects/DVDesign/Sources/Components/DVProjectContainer.swift index 5ac4a7b2..980c3cb9 100644 --- a/Projects/DVDesign/Sources/Components/DVProjectContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVProjectContainer.swift @@ -13,14 +13,12 @@ public struct DVProjectContainer: View { public let name: String /// nil이면 개수 라벨을 그리지 않는다 (`DVCategory.count`와 같은 규칙). public let count: Int? - public let isSelected: Bool // MARK: - Init - public init(name: String, count: Int?, isSelected: Bool = false) { + public init(name: String, count: Int?) { self.name = name self.count = count - self.isSelected = isSelected } // MARK: - Body @@ -34,7 +32,6 @@ public struct DVProjectContainer: View { } .padding(2) .frame(minWidth: 120, alignment: .leading) - .animation(MotionMetrics.hover, value: isSelected) } } @@ -45,13 +42,13 @@ extension DVProjectContainer { private var projectIcon: some View { Image(systemName: DVProjectContainer.projectIconSystemName) .dvFont(.captionLG) - .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray900)) + .foregroundStyle(.primary) } private var nameLabel: some View { Text(name) .dvFont(.bodyMD) - .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray900)) + .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.tail) .frame(minWidth: 40, alignment: .leading) @@ -62,7 +59,7 @@ extension DVProjectContainer { if let count { Text(count > 999 ? "999+" : "\(count)") .dvFont(.bodyMD) - .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray400)) + .foregroundStyle(.secondary) .fixedSize() } } diff --git a/Projects/DVDesign/Sources/Components/DVTextField.swift b/Projects/DVDesign/Sources/Components/DVTextField.swift index e97fa3c3..d93dc897 100644 --- a/Projects/DVDesign/Sources/Components/DVTextField.swift +++ b/Projects/DVDesign/Sources/Components/DVTextField.swift @@ -16,6 +16,8 @@ public struct DVTextField: View { @Binding private var text: String private let size: DVComponentSize private let isSecure: Bool + private let revealAccessibilityLabel: String + private let hideAccessibilityLabel: String @State private var isRevealed = false @FocusState private var isFocused: Bool @@ -27,16 +29,22 @@ public struct DVTextField: View { /// - text: 입력 값 양방향 바인딩. /// - size: 너비 변형. 기본 ``DVComponentSize/md``. /// - isSecure: 민감 값 마스킹 여부. 기본 `false`. + /// - revealAccessibilityLabel: 마스킹 해제(표시) 눈 아이콘의 VoiceOver 라벨. 호출부가 번역해서 넘긴다. + /// - hideAccessibilityLabel: 마스킹(숨김) 눈 아이콘의 VoiceOver 라벨. public init( _ placeholder: String, text: Binding, size: DVComponentSize = .md, - isSecure: Bool = false + isSecure: Bool = false, + revealAccessibilityLabel: String = "Show", + hideAccessibilityLabel: String = "Hide" ) { self.placeholder = placeholder self._text = text self.size = size self.isSecure = isSecure + self.revealAccessibilityLabel = revealAccessibilityLabel + self.hideAccessibilityLabel = hideAccessibilityLabel } // MARK: - Body @@ -102,6 +110,7 @@ extension DVTextField { .frame(width: 24, height: 24) } .buttonStyle(.plain) + .accessibilityLabel(isRevealed ? hideAccessibilityLabel : revealAccessibilityLabel) } /// Field editor의 커서를 문자열 끝으로 이동 (`@FocusState = true` 시 macOS 기본 "전체 선택" 회피). diff --git a/Projects/DVDesign/Sources/Components/DVTitleBar.swift b/Projects/DVDesign/Sources/Components/DVTitleBar.swift index c34af4ad..8e7863cb 100644 --- a/Projects/DVDesign/Sources/Components/DVTitleBar.swift +++ b/Projects/DVDesign/Sources/Components/DVTitleBar.swift @@ -30,6 +30,8 @@ public struct DVTitleBar: View { /// 검색 필드의 포커스를 바깥에서 풀 수 있게 열어 둔다 — 한 번 커서가 들어가면 다른 곳을 /// 눌러도 놓지 않는 경우가 있다. `nil`이면 시스템에 맡긴다. public let isSearchFocused: Binding? + /// 정렬 버튼의 VoiceOver 라벨. DVDesign엔 로컬라이저가 없어 호출부가 번역해서 넘긴다. + public let sortAccessibilityLabel: String @FocusState private var searchFieldFocused: Bool @State private var isSortHovered = false @@ -41,13 +43,15 @@ public struct DVTitleBar: View { searchText: Binding, searchPromptText: String = "Search", isSearchFocused: Binding? = nil, - sortMenuContent: (() -> AnyView)? = nil + sortMenuContent: (() -> AnyView)? = nil, + sortAccessibilityLabel: String = "Sort" ) { self.titleText = titleText self.searchText = searchText self.searchPromptText = searchPromptText self.isSearchFocused = isSearchFocused self.sortMenuContent = sortMenuContent + self.sortAccessibilityLabel = sortAccessibilityLabel } // MARK: - Body @@ -93,6 +97,7 @@ extension DVTitleBar { .fixedSize() .onHover { isSortHovered = $0 } .animation(MotionMetrics.hover, value: isSortHovered) + .accessibilityLabel(sortAccessibilityLabel) } private var searchField: some View { @@ -100,6 +105,7 @@ extension DVTitleBar { Image(systemName: "magnifyingglass") .dvFont(.bodyMD) .foregroundStyle(Color.dv(.gray500)) + .accessibilityHidden(true) TextField(searchPromptText, text: searchText) .dvFont(.bodyMD) .foregroundStyle(Color.dv(.gray900)) diff --git a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift index b6d9a80a..73aa5576 100644 --- a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift @@ -17,7 +17,6 @@ public struct DVVaultContainer: View { public let trailingIcon: DVExpiryEmphasis? /// `trailingIcon`에 hover 시 뜨는 설명 문구. `trailingIcon`이 `nil`이면 무시된다. public let trailingIconTooltip: String? - public let isSelected: Bool // MARK: - Init @@ -27,8 +26,7 @@ public struct DVVaultContainer: View { service: String? = nil, typeIcon: Image? = nil, trailingIcon: DVExpiryEmphasis? = nil, - trailingIconTooltip: String? = nil, - isSelected: Bool = false + trailingIconTooltip: String? = nil ) { self.name = name self.date = date @@ -36,7 +34,6 @@ public struct DVVaultContainer: View { self.typeIcon = typeIcon self.trailingIcon = trailingIcon self.trailingIconTooltip = trailingIconTooltip - self.isSelected = isSelected } // MARK: - Body @@ -50,7 +47,6 @@ public struct DVVaultContainer: View { } .padding(8) .frame(minWidth: 200, alignment: .leading) - .animation(MotionMetrics.hover, value: isSelected) } } @@ -129,12 +125,12 @@ extension DVVaultContainer { VStack(alignment: .leading, spacing: 6) { Text(name) .dvFont(.bodyLG) - .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray900)) + .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.tail) Text(date) .dvFont(.captionMDRegular) - .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray600)) + .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } @@ -145,10 +141,12 @@ extension DVVaultContainer { private var trailingIconView: some View { if let trailingIcon { trailingIcon.icon - .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(trailingIcon.colorToken)) + .foregroundStyle(Color.dv(trailingIcon.colorToken)) .fixedSize() // `.help(_:)`가 List 행 안에서 안 떠서 커스텀 말풍선으로 우회한다. .hoverTooltip(trailingIconTooltip) + // 툴팁은 마우스 전용이라 접근성 트리에 없다. 상태 문구를 VoiceOver에도 노출한다. + .accessibilityLabel(trailingIconTooltip ?? "") } } } diff --git a/Projects/DVDomain/DVDomain.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Projects/DVDomain/DVDomain.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Projects/DVDomain/DVDomain.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Projects/DVDomain/DVDomain.xcodeproj/xcshareddata/xcschemes/DVDomain.xcscheme b/Projects/DVDomain/DVDomain.xcodeproj/xcshareddata/xcschemes/DVDomain.xcscheme new file mode 100644 index 00000000..52be04d6 --- /dev/null +++ b/Projects/DVDomain/DVDomain.xcodeproj/xcshareddata/xcschemes/DVDomain.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVDomain/DVDomain.xcodeproj/xcshareddata/xcschemes/DVDomainContentTests.xcscheme b/Projects/DVDomain/DVDomain.xcodeproj/xcshareddata/xcschemes/DVDomainContentTests.xcscheme new file mode 100644 index 00000000..16b05323 --- /dev/null +++ b/Projects/DVDomain/DVDomain.xcodeproj/xcshareddata/xcschemes/DVDomainContentTests.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVDomain/Sources/Repository/Interface/SettingsRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/SettingsRepository.swift index 0f09a685..5ba07cf3 100644 --- a/Projects/DVDomain/Sources/Repository/Interface/SettingsRepository.swift +++ b/Projects/DVDomain/Sources/Repository/Interface/SettingsRepository.swift @@ -43,6 +43,16 @@ public protocol SettingsRepository: Sendable { /// - Parameter rawValue: 저장할 기본 환경의 rawValue func setDefaultEnvironment(_ rawValue: String) + /// 앱 전체에 적용할 화면 모드(rawValue: system/light/dark). + /// - Returns: 화면 모드의 rawValue + func appearance() -> String + /// 앱 전체에 적용할 화면 모드(rawValue)를 저장한다. + /// - Parameter rawValue: 저장할 화면 모드의 rawValue + func setAppearance(_ rawValue: String) + /// 구독을 시작하면 현재 화면 모드를 즉시 한 번 방출하고, 이후 변경될 때마다 최신값을 방출한다. + /// - Returns: 앱 화면 모드 스트림 + func appearanceStream() -> AsyncStream + // MARK: - Security /// 앱 실행 시 인증 요구 여부. diff --git a/Projects/DVDomain/Sources/Service/Interface/Notification/SecurityNotificationService.swift b/Projects/DVDomain/Sources/Service/Interface/Notification/SecurityNotificationService.swift index ebde3159..cdac4d12 100644 --- a/Projects/DVDomain/Sources/Service/Interface/Notification/SecurityNotificationService.swift +++ b/Projects/DVDomain/Sources/Service/Interface/Notification/SecurityNotificationService.swift @@ -16,4 +16,8 @@ public protocol SecurityNotificationService: Sendable { /// 예약된 알림을 식별자로 취소한다. 취소 자체는 실패하지 않는 연산이라 throws가 아니다. /// - Parameter identifiers: 취소할 알림 식별자 목록 func cancel(identifiers: [String]) async + + /// 아직 발송되지 않은(pending) 예약 알림들의 식별자 목록을 반환한다. + /// 원격 삭제 등으로 대상 Secret이 사라져 개별 취소가 불가능한 고아 알림을 걷어낼 때 쓴다. + func pendingIdentifiers() async -> [String] } diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift index 95b814ae..59820638 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift @@ -7,6 +7,7 @@ import DVCore public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiryNotificationsUseCase { private static let expiryNotificationHour = 9 + private static let expiryIDPrefix = "secret-expiry-" private let repository: any SecretRepository private let notificationService: any SecurityNotificationService @@ -33,6 +34,9 @@ public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiry // 만료일이 나중에 제거된 Secret의 정리도 같은 호출이 처리한다. await schedule(secret: secret) } + // 조회에 잡히지 않는(원격 삭제·전체 삭제 등으로 사라진) Secret의 고아 예약을 걷어낸다. + // 개별 취소는 ID를 알아야 하지만, pending 목록과 현재 Secret 집합의 차집합으로 특정한다. + await cancelOrphans(existing: secrets) } catch { throw SecretUseCaseError.map(error) } @@ -87,8 +91,31 @@ public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiry await notificationService.cancel(identifiers: identifiers) } + public func cancelAll() async { + let expiry = await pendingExpiryIdentifiers() + guard !expiry.isEmpty else { return } + await notificationService.cancel(identifiers: expiry) + } + + /// 현재 존재하는 Secret 집합에 속하지 않는 만료 알림(고아)을 취소한다. + /// 원격 삭제된 Secret은 조회 결과에 없어 ID를 모르므로, pending 목록에서 유효 식별자 집합의 + /// 차집합으로 특정한다. + private func cancelOrphans(existing secrets: [Secret]) async { + let valid = Set(secrets.flatMap { secret in + ExpiryAlertDay.allCases.map { Self.notificationID(secretID: secret.id, timing: $0) } + }) + let orphans = await pendingExpiryIdentifiers().filter { !valid.contains($0) } + guard !orphans.isEmpty else { return } + await notificationService.cancel(identifiers: orphans) + } + + /// pending 알림 중 만료 알림 식별자만 골라 반환한다(다른 종류의 알림은 건드리지 않는다). + private func pendingExpiryIdentifiers() async -> [String] { + await notificationService.pendingIdentifiers().filter { $0.hasPrefix(Self.expiryIDPrefix) } + } + private static func notificationID(secretID: UUID, timing: ExpiryAlertDay) -> String { - "secret-expiry-\(secretID.uuidString)-\(timing.rawValue)d" + "\(expiryIDPrefix)\(secretID.uuidString)-\(timing.rawValue)d" } private static func notificationDate(expiresAt: Date, timing: ExpiryAlertDay) -> Date? { diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Settings/DataSettingsUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Settings/DataSettingsUseCaseImpl.swift index ce2bd6a4..026361f4 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Settings/DataSettingsUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Settings/DataSettingsUseCaseImpl.swift @@ -4,15 +4,18 @@ public struct DataSettingsUseCaseImpl: DataSettingsUseCase { private let dataResetRepository: any DataResetRepository private let settingsRepository: any SettingsRepository private let authenticateUseCase: any AuthenticateUseCase + private let expiryNotificationScheduler: any ScheduleSecretExpiryNotificationsUseCase public init( dataResetRepository: any DataResetRepository, settingsRepository: any SettingsRepository, - authenticateUseCase: any AuthenticateUseCase + authenticateUseCase: any AuthenticateUseCase, + expiryNotificationScheduler: any ScheduleSecretExpiryNotificationsUseCase ) { self.dataResetRepository = dataResetRepository self.settingsRepository = settingsRepository self.authenticateUseCase = authenticateUseCase + self.expiryNotificationScheduler = expiryNotificationScheduler } public func isICloudSyncEnabled() -> Bool { @@ -22,5 +25,8 @@ public struct DataSettingsUseCaseImpl: DataSettingsUseCase { public func deleteAllData() async throws { try await authenticateUseCase.authenticate(reason: "Delete all data") try await dataResetRepository.deleteAll() + // 데이터가 사라졌으니 예약된 만료 알림도 함께 걷어낸다. 삭제 성공 후에만 취소해, + // 삭제가 실패하면(데이터가 남으면) 알림도 그대로 유지되게 한다. + await expiryNotificationScheduler.cancelAll() } } diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Settings/GeneralSettingsUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Settings/GeneralSettingsUseCaseImpl.swift index d4f46119..dcc6e13c 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Settings/GeneralSettingsUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Settings/GeneralSettingsUseCaseImpl.swift @@ -43,4 +43,16 @@ public struct GeneralSettingsUseCaseImpl: GeneralSettingsUseCase { public func setDefaultEnvironment(_ rawValue: String) { repository.setDefaultEnvironment(rawValue) } + + public func appearance() -> String { + repository.appearance() + } + + public func setAppearance(_ rawValue: String) { + repository.setAppearance(rawValue) + } + + public func appearanceStream() -> AsyncStream { + repository.appearanceStream() + } } diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Notification/ScheduleSecretExpiryNotificationsUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Notification/ScheduleSecretExpiryNotificationsUseCase.swift index 75ded582..663d4ae2 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Notification/ScheduleSecretExpiryNotificationsUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Notification/ScheduleSecretExpiryNotificationsUseCase.swift @@ -18,4 +18,10 @@ public protocol ScheduleSecretExpiryNotificationsUseCase: Sendable { /// 특정 Secret의 예약된 만료 알림을 모두 취소한다(삭제 시 호출). /// - Parameter secretID: 알림을 취소할 Secret의 ID func cancel(secretID: UUID) async + + /// 예약된 **모든** 만료 알림을 취소한다(전체 데이터 삭제 시 호출). + /// + /// 개별 `cancel(secretID:)`와 달리 취소 대상 ID를 몰라도 되며, pending 목록에서 + /// 만료 알림 식별자를 찾아 일괄 취소한다. + func cancelAll() async } diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Settings/GeneralSettingsUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Settings/GeneralSettingsUseCase.swift index add981a2..376c464c 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Settings/GeneralSettingsUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Settings/GeneralSettingsUseCase.swift @@ -20,4 +20,14 @@ public protocol GeneralSettingsUseCase: Sendable { /// 새 Secret 생성 시 적용할 기본 환경(rawValue)을 저장한다. /// - Parameter rawValue: 저장할 기본 환경의 rawValue func setDefaultEnvironment(_ rawValue: String) + + /// 앱 전체에 적용할 화면 모드(rawValue)를 반환한다. + /// - Returns: 화면 모드의 rawValue + func appearance() -> String + /// 앱 전체에 적용할 화면 모드(rawValue)를 저장한다. + /// - Parameter rawValue: 저장할 화면 모드의 rawValue + func setAppearance(_ rawValue: String) + /// 화면 모드 변경을 방출하는 스트림. 구독 즉시 현재값을 한 번 방출한다. + /// - Returns: 앱 화면 모드 스트림 + func appearanceStream() -> AsyncStream } diff --git a/Projects/DVDomain/Tests/Core/Support/FakeSecurityNotificationService.swift b/Projects/DVDomain/Tests/Core/Support/FakeSecurityNotificationService.swift index 0084306f..67c6d6da 100644 --- a/Projects/DVDomain/Tests/Core/Support/FakeSecurityNotificationService.swift +++ b/Projects/DVDomain/Tests/Core/Support/FakeSecurityNotificationService.swift @@ -5,31 +5,75 @@ import Foundation @testable import DVDomain /// 테스트용 SecurityNotificationService 구현. 호출 인자를 기록만 한다. +/// 자동 정리 등 백그라운드 Task에서도 `notify`가 동시에 호출될 수 있어 `NSLock`으로 상태를 보호한다 +/// (`FakeClipboardService`와 같은 이유). 보호하지 않으면 배열 동시 append로 크래시한다. public final class FakeSecurityNotificationService: SecurityNotificationService, @unchecked Sendable { - public var authorizationResult = true - public var errorOnNotify: SecurityNotificationError? - public var errorOnSchedule: SecurityNotificationError? - public private(set) var notified: [SecurityNotification] = [] - public private(set) var scheduled: [ScheduledSecurityNotification] = [] - public private(set) var cancelledIdentifiers: [[String]] = [] + private let lock = NSLock() + private var _authorizationResult = true + private var _errorOnNotify: SecurityNotificationError? + private var _errorOnSchedule: SecurityNotificationError? + private var _notified: [SecurityNotification] = [] + private var _scheduled: [ScheduledSecurityNotification] = [] + private var _cancelledIdentifiers: [[String]] = [] + private var _pending: Set = [] + + public var authorizationResult: Bool { + get { lock.lock(); defer { lock.unlock() }; return _authorizationResult } + set { lock.lock(); defer { lock.unlock() }; _authorizationResult = newValue } + } + public var errorOnNotify: SecurityNotificationError? { + get { lock.lock(); defer { lock.unlock() }; return _errorOnNotify } + set { lock.lock(); defer { lock.unlock() }; _errorOnNotify = newValue } + } + public var errorOnSchedule: SecurityNotificationError? { + get { lock.lock(); defer { lock.unlock() }; return _errorOnSchedule } + set { lock.lock(); defer { lock.unlock() }; _errorOnSchedule = newValue } + } + public var notified: [SecurityNotification] { + lock.lock(); defer { lock.unlock() }; return _notified + } + public var scheduled: [ScheduledSecurityNotification] { + lock.lock(); defer { lock.unlock() }; return _scheduled + } + public var cancelledIdentifiers: [[String]] { + lock.lock(); defer { lock.unlock() }; return _cancelledIdentifiers + } public init() {} public func requestAuthorization() async -> Bool { - authorizationResult + lock.lock(); defer { lock.unlock() }; return _authorizationResult } public func notify(_ notification: SecurityNotification) async throws { - if let error = errorOnNotify { throw error } - notified.append(notification) + lock.lock() + defer { lock.unlock() } + if let error = _errorOnNotify { throw error } + _notified.append(notification) } public func schedule(_ request: ScheduledSecurityNotification) async throws { - if let error = errorOnSchedule { throw error } - scheduled.append(request) + lock.lock() + defer { lock.unlock() } + if let error = _errorOnSchedule { throw error } + _scheduled.append(request) + _pending.insert(request.identifier) } public func cancel(identifiers: [String]) async { - cancelledIdentifiers.append(identifiers) + lock.lock() + defer { lock.unlock() } + _cancelledIdentifiers.append(identifiers) + identifiers.forEach { _pending.remove($0) } + } + + public func pendingIdentifiers() async -> [String] { + lock.lock(); defer { lock.unlock() }; return Array(_pending) + } + + /// 이전 세션에 예약돼 아직 남아 있는 알림을 흉내낸다(원격 삭제된 Secret의 고아 알림 등). + public func seedPending(_ identifiers: [String]) { + lock.lock(); defer { lock.unlock() } + identifiers.forEach { _pending.insert($0) } } } diff --git a/Projects/DVDomain/Tests/Core/Support/FakeSettingsRepository.swift b/Projects/DVDomain/Tests/Core/Support/FakeSettingsRepository.swift index 2e224c23..3a3a3dba 100644 --- a/Projects/DVDomain/Tests/Core/Support/FakeSettingsRepository.swift +++ b/Projects/DVDomain/Tests/Core/Support/FakeSettingsRepository.swift @@ -12,6 +12,8 @@ public final class FakeSettingsRepository: SettingsRepository, @unchecked Sendab public var isLaunchAtLoginEnabledValue = false public var defaultEnvironmentValue = "dev" + public var appearanceValue = "system" + public var appearanceStreamValue: AsyncStream? public var isRequireAuthOnLaunchEnabledValue = true public var isRequireAuthToCopyEnabledValue = true @@ -45,6 +47,18 @@ public final class FakeSettingsRepository: SettingsRepository, @unchecked Sendab public func defaultEnvironment() -> String { defaultEnvironmentValue } public func setDefaultEnvironment(_ rawValue: String) { defaultEnvironmentValue = rawValue } + public func appearance() -> String { appearanceValue } + public func setAppearance(_ rawValue: String) { appearanceValue = rawValue } + public func appearanceStream() -> AsyncStream { + if let appearanceStreamValue { + return appearanceStreamValue + } + return AsyncStream { continuation in + continuation.yield(appearanceValue) + continuation.finish() + } + } + public func isRequireAuthOnLaunchEnabled() -> Bool { isRequireAuthOnLaunchEnabledValue } public func setRequireAuthOnLaunchEnabled(_ enabled: Bool) { isRequireAuthOnLaunchEnabledValue = enabled } diff --git a/Projects/DVDomain/Tests/Core/UseCase/Notification/ScheduleSecretExpiryNotificationsUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Notification/ScheduleSecretExpiryNotificationsUseCaseImplTests.swift index c97db3f1..07ee6320 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Notification/ScheduleSecretExpiryNotificationsUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Notification/ScheduleSecretExpiryNotificationsUseCaseImplTests.swift @@ -336,4 +336,64 @@ struct ScheduleSecretExpiryNotificationsUseCaseImplTests { #expect(notificationService.scheduled.isEmpty) } + + @Test("syncAll은 더 이상 존재하지 않는(원격 삭제된) Secret의 고아 예약을 취소한다") + func syncAllCancelsOrphanNotificationsForVanishedSecrets() async throws { + let repository = InMemorySecretRepository() + let existing = SecretFixture.make( + id: UUID(uuidString: "00000000-0000-0000-0000-0000000000BB")!, + expiresAt: now.addingTimeInterval(10 * day) + ) + repository.seed(existing) + + let notificationService = FakeSecurityNotificationService() + // 이전 세션에 예약됐지만 지금은 repo에 없는(원격 삭제된) Secret의 알림. + let orphanID = UUID(uuidString: "00000000-0000-0000-0000-0000000000FF")! + notificationService.seedPending([ + "secret-expiry-\(orphanID.uuidString)-30d", + "secret-expiry-\(orphanID.uuidString)-7d", + ]) + let sut = ScheduleSecretExpiryNotificationsUseCaseImpl( + repository: repository, + notificationService: notificationService, + settingsRepository: FakeSettingsRepository(), + dateProvider: { self.now } + ) + + try await sut.syncAll() + + let pending = Set(await notificationService.pendingIdentifiers()) + // 고아 알림은 사라지고 + #expect(!pending.contains("secret-expiry-\(orphanID.uuidString)-30d")) + #expect(!pending.contains("secret-expiry-\(orphanID.uuidString)-7d")) + // 존재하는 Secret의 예약은 남는다(10일 후 만료 → 7일 전 마크가 미래). + #expect(pending.contains("secret-expiry-\(existing.id.uuidString)-7d")) + } + + // MARK: - cancelAll() + + @Test("cancelAll은 예약된 모든 만료 알림을 취소하고, 만료 알림이 아닌 것은 건드리지 않는다") + func cancelAllCancelsAllPendingExpiryNotificationsOnly() async { + let notificationService = FakeSecurityNotificationService() + let idA = UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")! + let idB = UUID(uuidString: "00000000-0000-0000-0000-0000000000B2")! + notificationService.seedPending([ + "secret-expiry-\(idA.uuidString)-30d", + "secret-expiry-\(idB.uuidString)-3d", + "some-other-notification", // 만료 알림 접두어가 아니므로 유지되어야 한다 + ]) + let sut = ScheduleSecretExpiryNotificationsUseCaseImpl( + repository: InMemorySecretRepository(), + notificationService: notificationService, + settingsRepository: FakeSettingsRepository(), + dateProvider: { self.now } + ) + + await sut.cancelAll() + + let pending = Set(await notificationService.pendingIdentifiers()) + #expect(!pending.contains("secret-expiry-\(idA.uuidString)-30d")) + #expect(!pending.contains("secret-expiry-\(idB.uuidString)-3d")) + #expect(pending == ["some-other-notification"]) + } } diff --git a/Projects/DVDomain/Tests/Core/UseCase/Settings/DataSettingsUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Settings/DataSettingsUseCaseImplTests.swift index 9414199b..7539b433 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Settings/DataSettingsUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Settings/DataSettingsUseCaseImplTests.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import Foundation import Testing @testable import DVDomain @@ -18,7 +19,8 @@ struct DataSettingsUseCaseImplTests { authenticationService: StubUserAuthenticationService(), notificationService: FakeSecurityNotificationService(), settingsRepository: settingsRepository - ) + ), + expiryNotificationScheduler: SpyExpiryNotificationScheduler() ) #expect(sut.isICloudSyncEnabled()) @@ -34,7 +36,8 @@ struct DataSettingsUseCaseImplTests { authenticationService: StubUserAuthenticationService(), notificationService: FakeSecurityNotificationService(), settingsRepository: FakeSettingsRepository() - ) + ), + expiryNotificationScheduler: SpyExpiryNotificationScheduler() ) try await sut.deleteAllData() @@ -42,9 +45,29 @@ struct DataSettingsUseCaseImplTests { #expect(dataResetRepository.deleteAllCount == 1) } - @Test("인증에 실패하면 아무것도 삭제하지 않는다") + @Test("전체 삭제에 성공하면 예약된 만료 알림도 모두 취소한다") + func deleteAllDataCancelsExpiryNotifications() async throws { + let scheduler = SpyExpiryNotificationScheduler() + let sut = DataSettingsUseCaseImpl( + dataResetRepository: FakeDataResetRepository(), + settingsRepository: FakeSettingsRepository(), + authenticateUseCase: AuthenticateUseCaseImpl( + authenticationService: StubUserAuthenticationService(), + notificationService: FakeSecurityNotificationService(), + settingsRepository: FakeSettingsRepository() + ), + expiryNotificationScheduler: scheduler + ) + + try await sut.deleteAllData() + + #expect(scheduler.cancelAllCount == 1) + } + + @Test("인증에 실패하면 아무것도 삭제하지 않고 알림도 취소하지 않는다") func deleteAllDataDoesNothingWhenAuthenticationFails() async { let dataResetRepository = FakeDataResetRepository() + let scheduler = SpyExpiryNotificationScheduler() let authenticationService = StubUserAuthenticationService() authenticationService.errorOnAuthenticate = .cancelled let sut = DataSettingsUseCaseImpl( @@ -54,19 +77,22 @@ struct DataSettingsUseCaseImplTests { authenticationService: authenticationService, notificationService: FakeSecurityNotificationService(), settingsRepository: FakeSettingsRepository() - ) + ), + expiryNotificationScheduler: scheduler ) await #expect(throws: UserAuthenticationError.cancelled) { try await sut.deleteAllData() } #expect(dataResetRepository.deleteAllCount == 0) + #expect(scheduler.cancelAllCount == 0) } - @Test("저장소 초기화 실패를 그대로 전달한다") + @Test("저장소 초기화 실패 시 실패를 전달하고 알림도 취소하지 않는다") func deleteAllDataRethrowsDataResetFailure() async { let dataResetRepository = FakeDataResetRepository() dataResetRepository.error = .resetFailed + let scheduler = SpyExpiryNotificationScheduler() let sut = DataSettingsUseCaseImpl( dataResetRepository: dataResetRepository, settingsRepository: FakeSettingsRepository(), @@ -74,12 +100,15 @@ struct DataSettingsUseCaseImplTests { authenticationService: StubUserAuthenticationService(), notificationService: FakeSecurityNotificationService(), settingsRepository: FakeSettingsRepository() - ) + ), + expiryNotificationScheduler: scheduler ) await #expect(throws: DataResetRepositoryError.resetFailed) { try await sut.deleteAllData() } + // 삭제가 실패해 데이터가 남았으므로 알림도 유지되어야 한다. + #expect(scheduler.cancelAllCount == 0) } } @@ -92,3 +121,12 @@ private final class FakeDataResetRepository: DataResetRepository, @unchecked Sen if let error { throw error } } } + +private final class SpyExpiryNotificationScheduler: ScheduleSecretExpiryNotificationsUseCase, @unchecked Sendable { + private(set) var cancelAllCount = 0 + + func syncAll() async throws {} + func schedule(secret: Secret) async {} + func cancel(secretID: UUID) async {} + func cancelAll() async { cancelAllCount += 1 } +} diff --git a/Projects/DVDomain/Tests/Core/UseCase/Settings/GeneralSettingsUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Settings/GeneralSettingsUseCaseImplTests.swift index 37e37ec9..eae3c30f 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Settings/GeneralSettingsUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Settings/GeneralSettingsUseCaseImplTests.swift @@ -98,4 +98,17 @@ struct GeneralSettingsUseCaseImplTests { sut.setDefaultEnvironment("prod") #expect(sut.defaultEnvironment() == "prod") } + + @Test("화면 모드 설정을 읽고 쓴다") + func appearanceRoundTrips() { + let repository = FakeSettingsRepository() + let sut = GeneralSettingsUseCaseImpl( + repository: repository, + launchAtLoginService: FakeLaunchAtLoginService() + ) + + #expect(sut.appearance() == "system") + sut.setAppearance("dark") + #expect(sut.appearance() == "dark") + } } diff --git a/Projects/DVDomain/Tests/Core/UseCase/Settings/NotificationSettingsUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Settings/NotificationSettingsUseCaseImplTests.swift index ed070f9d..91258523 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Settings/NotificationSettingsUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Settings/NotificationSettingsUseCaseImplTests.swift @@ -65,4 +65,5 @@ private final class StubExpiryNotificationScheduler: ScheduleSecretExpiryNotific func schedule(secret: Secret) async {} func cancel(secretID: UUID) async {} + func cancelAll() async {} } diff --git a/Projects/DVNetwork/DVNetwork.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Projects/DVNetwork/DVNetwork.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Projects/DVNetwork/DVNetwork.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Projects/DVNetwork/DVNetwork.xcodeproj/xcshareddata/xcschemes/DVNetwork.xcscheme b/Projects/DVNetwork/DVNetwork.xcodeproj/xcshareddata/xcschemes/DVNetwork.xcscheme new file mode 100644 index 00000000..847aaa4c --- /dev/null +++ b/Projects/DVNetwork/DVNetwork.xcodeproj/xcshareddata/xcschemes/DVNetwork.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVPresentation/DVPresentation.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Projects/DVPresentation/DVPresentation.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Projects/DVPresentation/DVPresentation.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Projects/DVPresentation/DVPresentation.xcodeproj/xcshareddata/xcschemes/DVPresentation.xcscheme b/Projects/DVPresentation/DVPresentation.xcodeproj/xcshareddata/xcschemes/DVPresentation.xcscheme new file mode 100644 index 00000000..edbf920b --- /dev/null +++ b/Projects/DVPresentation/DVPresentation.xcodeproj/xcshareddata/xcschemes/DVPresentation.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVPresentation/DVPresentation.xcodeproj/xcshareddata/xcschemes/DVPresentation_DVPresentation.xcscheme b/Projects/DVPresentation/DVPresentation.xcodeproj/xcshareddata/xcschemes/DVPresentation_DVPresentation.xcscheme new file mode 100644 index 00000000..7d96b9eb --- /dev/null +++ b/Projects/DVPresentation/DVPresentation.xcodeproj/xcshareddata/xcschemes/DVPresentation_DVPresentation.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 663c167a..7fd7e378 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -56,88 +56,88 @@ } } }, - "15 min": { - "comment": "A tag for a 15-minute selection in the settings picker row.", + "3 days before expiration": { + "comment": "Label for an expiry alert day option that corresponds to 3 days before expiration.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "15 min" + "value": "3 days before expiration" } }, "ko": { "stringUnit": { "state": "translated", - "value": "15분" + "value": "만료 3일 전" } } } }, - "3 days before expiration": { - "comment": "Label for an expiry alert day option that corresponds to 3 days before expiration.", + "5 min": { + "comment": "A selectable option in the settings picker row.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "3 days before expiration" + "value": "5 min" } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료 3일 전" + "value": "5분" } } } }, - "30 days before expiration": { + "7 days before expiration": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "30 days before expiration" + "value": "7 days before expiration" } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료 30일 전" + "value": "만료 7일 전" } } } }, - "5 min": { - "comment": "A selectable option in the settings picker row.", + "15 min": { + "comment": "A tag for a 15-minute selection in the settings picker row.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "5 min" + "value": "15 min" } }, "ko": { "stringUnit": { "state": "translated", - "value": "5분" + "value": "15분" } } } }, - "7 days before expiration": { + "30 days before expiration": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "7 days before expiration" + "value": "30 days before expiration" } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료 7일 전" + "value": "만료 30일 전" } } } @@ -153,7 +153,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트를 불러오는 중 데이터베이스 오류가 발생했어요. 프로젝트 목록이 일부만 표시될 수 있어요." + "value": "프로젝트를 불러오는 중 데이터베이스 오류가 발생했습니다. 프로젝트 목록이 일부만 표시될 수 있습니다." } } } @@ -171,7 +171,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "Secret을 저장하는 중 데이터베이스 오류가 발생했어요. 다시 시도해 주세요." + "value": "Secret을 저장하는 중 데이터베이스 오류가 발생했습니다. 다시 시도해 주십시오." } } } @@ -189,7 +189,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "저장된 Secret이 오늘 만료돼요." + "value": "저장된 Secret이 오늘 만료됩니다." } } } @@ -219,7 +219,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "저장된 Secret이 %lld일 후 만료돼요." + "value": "저장된 Secret이 %lld일 후 만료됩니다." } } } @@ -236,8 +236,8 @@ }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "Secret이 오늘 만료돼요." + "state": "translated", + "value": "Secret이 오늘 만료됩니다." } } } @@ -254,8 +254,8 @@ }, "ko": { "stringUnit": { - "state": "needs_review", - "value": "곧 만료되는 Secret이 있어요." + "state": "translated", + "value": "곧 만료되는 Secret이 있습니다." } } } @@ -273,303 +273,315 @@ "ko": { "stringUnit": { "state": "translated", - "value": "짧은 시간 동안 값이 %lld번 복사됐어요." + "value": "짧은 시간 동안 값이 %lld번 복사됐습니다." } } } }, - "API Key": { - "comment": "Label for the \"API Key\" option in the \"Create Secret\" screen.", + "Abnormal access detected.": { + "comment": "Title of the notification when an abnormal access is detected.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "API Key" + "value": "Abnormal access detected." } }, "ko": { "stringUnit": { "state": "translated", - "value": "API Key" + "value": "비정상적인 접근이 감지됐습니다." } } } }, - "API Keys/Token": { - "comment": "Title of the \"Create Secret\" screen's top label.", + "About": { + "comment": "Title of the \"About\" settings category.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "API Keys/Token" + "value": "About" } }, "ko": { "stringUnit": { "state": "translated", - "value": "API Keys/Token" + "value": "정보" } } } }, - "API Webhook Secret": { - "comment": "Label for the \"API Webhook Secret\" option in the \"Create Secret\" screen.", - "isCommentAutoGenerated": true, + "Access Token": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "API Webhook Secret" + "value": "Access Token" } }, "ko": { "stringUnit": { "state": "translated", - "value": "API Webhook Secret" + "value": "Access Token" } } } }, - "Abnormal access detected.": { - "comment": "Title of the notification when an abnormal access is detected.", - "isCommentAutoGenerated": true, + "Access your secrets on all your\nApple devices, securely encrypted.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Abnormal access detected." + "value": "Access your secrets on all your\nApple devices, securely encrypted." } }, "ko": { "stringUnit": { "state": "translated", - "value": "비정상적인 접근이 감지됐어요." + "value": "모든 Apple 기기에서 암호화된 Secret에 안전하게 접근할 수 있습니다." } } } }, - "About": { - "comment": "Title of the \"About\" settings category.", + "Add new project": { + "comment": "Label for the \"Add new project\" button in the \"Project\" field of the CreateSecret form.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "About" + "value": "Add new project" } }, "ko": { "stringUnit": { "state": "translated", - "value": "정보" + "value": "새 프로젝트 추가" } } } }, - "Access Token": { + "Add Project": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Access Token" + "value": "Add Project" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Access Token" + "value": "프로젝트 추가" } } } }, - "Access your secrets on all your\nApple devices, securely encrypted.": { + "Add Secret": { + "comment": "Text for the button that allows the user to add a new secret.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Access your secrets on all your\nApple devices, securely encrypted." + "value": "Add Secret" } }, "ko": { "stringUnit": { "state": "translated", - "value": "모든 Apple 기기에서 안전하게 암호화된 Secret에 접근하세요." + "value": "Secret 추가" } } } }, - "Add Project": { + "Add to favorites": { + "comment": "Accessibility label for the \"Add to favorites\" button in the secret detail header.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Add Project" + "value": "Add to favorites" } }, "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트 추가" + "value": "즐겨찾기에 추가" } } } }, - "Add Secret": { - "comment": "Text for the button that allows the user to add a new secret.", + "Alert on repeated authentication failures": { + "comment": "Text displayed in a settings row that allows the user to enable or disable alerts when repeated authentication failures occur.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Add Secret" + "value": "Alert on repeated authentication failures" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Secret 추가" + "value": "인증 반복 실패 시 알림" } } } }, - "Add new project": { - "comment": "Label for the \"Add new project\" button in the \"Project\" field of the CreateSecret form.", + "Alert on repeated clipboard copies": { + "comment": "Description of a setting that alerts the user when they copy from the clipboard more than once.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Add new project" + "value": "Alert on repeated clipboard copies" } }, "ko": { "stringUnit": { "state": "translated", - "value": "새 프로젝트 추가" + "value": "클립보드 반복 복사 시 알림" } } } }, - "Add to favorites": { - "comment": "Accessibility label for the \"Add to favorites\" button in the secret detail header.", + "Alert timing": { + "comment": "Label for the timing of the expiration alert.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Add to favorites" + "value": "Alert timing" } }, "ko": { "stringUnit": { "state": "translated", - "value": "즐겨찾기에 추가" + "value": "알림 시점" } } } }, - "Alert on repeated authentication failures": { - "comment": "Text displayed in a settings row that allows the user to enable or disable alerts when repeated authentication failures occur.", + "All": { + "comment": "Title for the \"All\" tab in the Secret List.", "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체" + } + } + } + }, + "Allow DeVault in System Settings to launch it automatically when you log in.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Alert on repeated authentication failures" + "value": "Allow DeVault in System Settings to launch it automatically when you log in." } }, "ko": { "stringUnit": { "state": "translated", - "value": "인증 반복 실패 시 알림" + "value": "로그인 시 자동으로 실행하려면 시스템 설정에서 DeVault를 허용해야 합니다." } } } }, - "Alert on repeated clipboard copies": { - "comment": "Description of a setting that alerts the user when they copy from the clipboard more than once.", - "isCommentAutoGenerated": true, + "An unexpected error occurred. Please try again.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Alert on repeated clipboard copies" + "value": "An unexpected error occurred. Please try again." } }, "ko": { "stringUnit": { "state": "translated", - "value": "클립보드 반복 복사 시 알림" + "value": "알 수 없는 오류가 발생했습니다. 다시 시도해 주십시오." } } } }, - "Alert timing": { - "comment": "Label for the timing of the expiration alert.", + "An unexpected error occurred. Your changes were not saved and are still here.": { + "comment": "Text displayed in a notification when an unexpected error occurred while saving changes.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Alert timing" + "value": "An unexpected error occurred. Your changes were not saved and are still here." } }, "ko": { "stringUnit": { "state": "translated", - "value": "알림 시점" + "value": "알 수 없는 오류가 발생했습니다. 변경 사항이 저장되지 않았지만 그대로 남아 있습니다." } } } }, - "Allow DeVault in System Settings to launch it automatically when you log in.": { + "API Key": { + "comment": "Label for the \"API Key\" option in the \"Create Secret\" screen.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Allow DeVault in System Settings to launch it automatically when you log in." + "value": "API Key" } }, "ko": { "stringUnit": { "state": "translated", - "value": "시스템 설정에서 DeVault가 로그인 시 자동으로 실행되도록 허용하세요." + "value": "API Key" } } } }, - "An unexpected error occurred. Please try again.": { + "API Keys/Token": { + "comment": "Title of the \"Create Secret\" screen's top label.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "An unexpected error occurred. Please try again." + "value": "API Keys/Token" } }, "ko": { "stringUnit": { "state": "translated", - "value": "알 수 없는 오류가 발생했어요. 다시 시도해 주세요." + "value": "API Keys/Token" } } } }, - "An unexpected error occurred. Your changes were not saved and are still here.": { - "comment": "Text displayed in a notification when an unexpected error occurred while saving changes.", + "API Webhook Secret": { + "comment": "Label for the \"API Webhook Secret\" option in the \"Create Secret\" screen.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "An unexpected error occurred. Your changes were not saved and are still here." + "value": "API Webhook Secret" } }, "ko": { "stringUnit": { "state": "translated", - "value": "알 수 없는 오류가 발생했어요. 변경 사항이 저장되지 않았지만 그대로 남아있어요." + "value": "API Webhook Secret" } } } @@ -592,6 +604,23 @@ } } }, + "Appearance": { + "comment": "Section title in the General Settings view for the app appearance (theme) setting.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화면 모드" + } + } + } + }, "Approval required": { "localizations": { "en": { @@ -675,7 +704,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "짧은 시간 동안 인증이 %lld번 실패했어요." + "value": "짧은 시간 동안 인증이 %lld번 실패했습니다." } } } @@ -714,68 +743,68 @@ } } }, - "Auto-Lock": { - "comment": "Section title in the Security Settings view for the Auto-Lock settings.", + "Auto-clear clipboard": { + "comment": "Title for the setting that enables or disables automatic clearing of the clipboard after a certain period.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Auto-Lock" + "value": "Auto-clear clipboard" } }, "ko": { "stringUnit": { "state": "translated", - "value": "자동 잠금" + "value": "클립보드 자동 지우기" } } } }, - "Auto-clear clipboard": { - "comment": "Title for the setting that enables or disables automatic clearing of the clipboard after a certain period.", + "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, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Auto-clear clipboard" + "value": "Auto-detected: %@" } }, "ko": { "stringUnit": { "state": "translated", - "value": "클립보드 자동 지우기" + "value": "자동 감지됨: %@" } } } }, - "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.", + "Auto-lock": { + "comment": "Title of the toggle that enables or disables automatic locking after inactivity.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Auto-detected: %@" + "value": "Auto-lock" } }, "ko": { "stringUnit": { "state": "translated", - "value": "자동 감지됨: %@" + "value": "자동 잠금" } } } }, - "Auto-lock": { - "comment": "Title of the toggle that enables or disables automatic locking after inactivity.", + "Auto-Lock": { + "comment": "Section title in the Security Settings view for the Auto-Lock settings.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Auto-lock" + "value": "Auto-Lock" } }, "ko": { @@ -799,7 +828,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "새 Secret을 만들 때 자동으로 적용돼요." + "value": "새 Secret을 만들 때 자동으로 적용됩니다." } } } @@ -817,7 +846,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "Mac에 로그인할 때 DeVault를 자동으로 실행해요." + "value": "Mac에 로그인할 때 DeVault를 자동으로 실행합니다." } } } @@ -923,7 +952,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "시스템 설정에서 로그인 암호가 설정되어 있는지 확인하세요." + "value": "시스템 설정에서 로그인 암호가 설정되어 있는지 확인해 주십시오." } } } @@ -939,7 +968,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "기기의 iCloud 사용 제한을 확인하세요." + "value": "기기의 iCloud 사용 제한을 확인해 주십시오." } } } @@ -957,7 +986,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "네트워크 연결을 확인한 뒤 다시 시도해 주세요." + "value": "네트워크 연결을 확인한 뒤 다시 시도해 주십시오." } } } @@ -978,6 +1007,35 @@ } } }, + "Choose light or dark, or match your system setting.": { + "comment": "Description for the appearance/theme picker in the General Settings view.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose light or dark, or match your system setting." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이트 또는 다크를 선택하거나 시스템 설정을 따릅니다." + } + } + } + }, + "Clear": { + "comment": "Accessibility label for the \"Clear\" button in the Expire Date field.", + "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "지우기" + } + } + } + }, "Clear after": { "comment": "Label for the \"Clear after\" option in the \"Clipboard\" section of the security settings view.", "isCommentAutoGenerated": true, @@ -1061,7 +1119,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "클립보드를 지웠어요." + "value": "클립보드를 지웠습니다." } } } @@ -1102,6 +1160,18 @@ } } }, + "Contact": { + "comment": "Title of a link row in the \"Support\" section of the About Settings view that takes the user to the \"Contact\" section of the help center.", + "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "문의" + } + } + } + }, "Continue Without iCloud": { "comment": "Text for a button that allows the user to continue using the app without syncing with iCloud.", "isCommentAutoGenerated": true, @@ -1120,6 +1190,18 @@ } } }, + "Copy": { + "comment": "Label for the \"Copy\" button in the DetailReadOnlyFieldView.", + "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "복사" + } + } + } + }, "Couldn't complete the action.": { "comment": "Alert title when a secret list mutation (delete/recover) fails.", "localizations": { @@ -1132,7 +1214,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "작업을 완료하지 못했어요." + "value": "작업을 완료하지 못했습니다." } } } @@ -1149,7 +1231,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트를 만들지 못했어요." + "value": "프로젝트를 만들지 못했습니다." } } } @@ -1167,7 +1249,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "데이터를 삭제하지 못했어요." + "value": "데이터를 삭제하지 못했습니다." } } } @@ -1184,7 +1266,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트를 삭제하지 못했어요." + "value": "프로젝트를 삭제하지 못했습니다." } } } @@ -1202,7 +1284,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "iCloud 상태를 확인할 수 없어요. 잠시 후 다시 시도해 주세요." + "value": "iCloud 상태를 확인할 수 없습니다. 잠시 후 다시 시도해 주십시오." } } } @@ -1219,7 +1301,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "이름을 변경하지 못했어요." + "value": "이름을 변경하지 못했습니다." } } } @@ -1235,7 +1317,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "만료 알림을 업데이트하지 못했어요." + "value": "만료 알림을 업데이트하지 못했습니다." } } } @@ -1288,14 +1370,30 @@ } } }, - "Data": { - "comment": "Title of the settings category related to data management.", - "isCommentAutoGenerated": true, + "Dark": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Data" + "value": "Dark" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크" + } + } + } + }, + "Data": { + "comment": "Title of the settings category related to data management.", + "isCommentAutoGenerated": true, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Data" } }, "ko": { @@ -1317,7 +1415,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "이 Mac과 iCloud의 데이터는 삭제되지 않아요. iCloud 동기화를 다시 켜기 전까지 이 Mac에서의 변경 사항은 동기화되지 않아요." + "value": "이 Mac과 iCloud의 데이터는 삭제되지 않습니다. iCloud 동기화를 다시 켜기 전까지 이 Mac에서의 변경 사항은 동기화되지 않습니다." } } } @@ -1339,17 +1437,15 @@ } }, "De": { - "shouldTranslate": false - }, - "DeVault": { - "comment": "Name of the company behind the app.", - "isCommentAutoGenerated": true, - "shouldTranslate": false - }, - "DeVault Team": { - "comment": "Name of the DeVault team.", - "isCommentAutoGenerated": true, - "shouldTranslate": false + "shouldTranslate": false, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "De" + } + } + } }, "Default environment": { "comment": "Title of a section in the General Settings view that describes the default environment setting.", @@ -1450,7 +1546,7 @@ "ko": { "stringUnit": { "state": "translated", - "value": "영구 삭제할까요?" + "value": "영구적으로 삭제하시겠습니까?" } } } @@ -1483,7 +1579,19 @@ "ko": { "stringUnit": { "state": "translated", - "value": "'%@' 프로젝트를 삭제할까요?" + "value": "'%@' 프로젝트를 삭제하시겠습니까?" + } + } + } + }, + "Deleted": { + "comment": "Title of the \"Deleted\" tab in the Secret List view.", + "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제됨" } } } @@ -1523,6 +1631,42 @@ } } }, + "DeVault": { + "comment": "Name of the company behind the app.", + "isCommentAutoGenerated": true, + "shouldTranslate": false, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "DeVault" + } + } + } + }, + "DeVault Help": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "DeVault 도움말" + } + } + } + }, + "DeVault Team": { + "comment": "Name of the DeVault team.", + "isCommentAutoGenerated": true, + "shouldTranslate": false, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "DeVault 팀" + } + } + } + }, "Developer": { "comment": "Title of a section in the \"About\" settings view that lists the name of the Devault team.", "isCommentAutoGenerated": true, @@ -1602,3175 +1746,3476 @@ "ko": { "stringUnit": { "state": "translated", - "value": "변경 사항을 취소할까요?" + "value": "변경 사항을 취소하시겠습니까?" } } } }, - "Edit": { - "comment": "Accessibility label for the \"Edit\" button in the secret detail header.", + "Done": { + "comment": "Title of the button that dismisses the current view.", "isCommentAutoGenerated": true, "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, "ko": { "stringUnit": { "state": "translated", - "value": "편집" + "value": "완료" } } } }, - "Editing is unavailable until the linked projects load. Other details are unaffected.": { + "e.g -----BEGIN CERTIFICATE-----": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Editing is unavailable until the linked projects load. Other details are unaffected." + "value": "e.g -----BEGIN CERTIFICATE-----" } }, "ko": { "stringUnit": { "state": "translated", - "value": "연결된 프로젝트를 불러올 때까지 편집할 수 없어요. 다른 정보에는 영향이 없어요." + "value": "예: -----BEGIN CERTIFICATE-----" } } } }, - "Enable Sync": { + "e.g -----BEGIN OPENSSH PRIVATE KEY-----": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enable Sync" + "value": "e.g -----BEGIN OPENSSH PRIVATE KEY-----" } }, "ko": { "stringUnit": { "state": "translated", - "value": "동기화 사용" + "value": "예: -----BEGIN OPENSSH PRIVATE KEY-----" } } } }, - "Enable Touch ID": { + "e.g -----BEGIN PRIVATE KEY-----": { + "comment": "Placeholder text for the private key field in the SSL/TLS certificate form section.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enable Touch ID" + "value": "e.g -----BEGIN PRIVATE KEY-----" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Touch ID 사용" + "value": "예: -----BEGIN PRIVATE KEY-----" } } } }, - "Enable expiration alerts": { - "comment": "Title of a toggle row that allows the user to enable or disable expiration alerts.", - "isCommentAutoGenerated": true, + "e.g {\"type\": \"service_account\", ...}": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enable expiration alerts" + "value": "e.g {\"type\": \"service_account\", ...}" } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료 알림 사용" + "value": "예: {\"type\": \"service_account\", ...}" } } } }, - "Encryption unavailable.": { + "e.g abc123secret": { + "comment": "Placeholder text for the \"Client Secret\" text field in the \"OAuth Client\" form section.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Encryption unavailable." + "value": "e.g abc123secret" } }, "ko": { "stringUnit": { "state": "translated", - "value": "암호화를 사용할 수 없어요." + "value": "예: abc123secret" } } } }, - "Enterprise": { - "comment": "Description of a license tier when the user is an enterprise user.", - "isCommentAutoGenerated": true, + "e.g certbot renew": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enterprise" + "value": "e.g certbot renew" } }, "ko": { "stringUnit": { "state": "translated", - "value": "기업" + "value": "예: certbot renew" } } } }, - "Environment": { + "e.g custom-secret-value": { + "comment": "Placeholder text for the value field in the custom secret form.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Environment" + "value": "e.g custom-secret-value" } }, "ko": { "stringUnit": { "state": "translated", - "value": "환경" + "value": "예: custom-secret-value" } } } }, - "Environment Variable Set": { + "e.g deploy.example.com": { + "comment": "Placeholder text for a label.", + "extractionState": "stale", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Environment Variable Set" + "value": "e.g deploy.example.com" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Environment Variable Set" + "value": "예: deploy.example.com" } } } }, - "Etc": { + "e.g DeVault": { + "comment": "Placeholder text for the Name field in the CreateSecret form.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Etc" + "value": "e.g DeVault" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Etc" + "value": "예: DeVault" } } } }, - "Excludes the DeVault window from screenshots, screen recordings, and screen sharing.": { + "e.g example.com": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Excludes the DeVault window from screenshots, screen recordings, and screen sharing." + "value": "e.g example.com" } }, "ko": { "stringUnit": { "state": "translated", - "value": "스크린샷, 화면 녹화, 화면 공유에서 DeVault 창을 제외해요." + "value": "예: example.com" } } } }, - "Expand Projects": { - "comment": "Accessibility label for the button that expands the list of projects.", + "e.g FOO=bar": { + "comment": "Placeholder text for the \"envSet List\" text field in the form.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Expand Projects" + "value": "e.g FOO=bar" } }, "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트 펼치기" + "value": "예: FOO=bar" } } } }, - "Expiration Alerts": { - "comment": "Title of the section that controls expiration alerts.", - "isCommentAutoGenerated": true, + "e.g ghp_1234567890": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Expiration Alerts" + "value": "e.g ghp_1234567890" } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료 알림" + "value": "예: ghp_1234567890" } } } }, - "Expire Date": { - "comment": "Label for the \"Expire Date\" field in the \"Create Secret\" form.", - "isCommentAutoGenerated": true, + "e.g https://app.example/oauth/callback": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Expire Date" + "value": "e.g https://app.example/oauth/callback" } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료일" + "value": "예: https://app.example/oauth/callback" } } } }, - "Expired": { - "comment": "Tooltip label shown when a secret has already expired.", + "e.g https://example.com": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Expired" + "value": "e.g https://example.com" } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료됨" + "value": "예: https://example.com" } } } }, - "Expires within %lld days": { - "comment": "A tooltip text for the `SecretExpiryStatus.critical` case.", - "isCommentAutoGenerated": true, + "e.g my-app-client": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Expires within %lld days" + "value": "e.g my-app-client" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%lld일 이내 만료" + "value": "예: my-app-client" } } } }, - "Expiry": { - "comment": "A label displayed in the context menu.", - "isCommentAutoGenerated": true, + "e.g ORD-2026-0001": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Expiry" + "value": "e.g ORD-2026-0001" } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료일" + "value": "예: ORD-2026-0001" } } } }, - "Failed to copy.": { - "comment": "Text displayed in a notification when a value could not be copied to the clipboard.", + "e.g organization-admin": { + "comment": "Placeholder text for the \"Authority / Scope\" text field in the `ServiceAccountSectionView`.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to copy." + "value": "e.g organization-admin" } }, "ko": { "stringUnit": { "state": "translated", - "value": "복사하지 못했어요." + "value": "예: organization-admin" } } } }, - "Failed to delete secret.": { - "comment": "Text displayed in an alert when a secret deletion fails.", + "e.g postgres://user:pass@host:5432/db": { + "comment": "Placeholder text for the \"Link String\" field in the \"Database\" form section.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to delete secret." + "value": "e.g postgres://user:pass@host:5432/db" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Secret을 삭제하지 못했어요." + "value": "예: postgres://user:pass@host:5432/db" } } } }, - "Failed to load linked projects": { + "e.g read:user, write:issue": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to load linked projects" + "value": "e.g read:user, write:issue" } }, "ko": { "stringUnit": { "state": "translated", - "value": "연결된 프로젝트를 불러오지 못했어요" + "value": "예: read:user, write:issue" } } } }, - "Failed to load projects.": { + "e.g repo:read, user:email": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to load projects." + "value": "e.g repo:read, user:email" } }, "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트를 불러오지 못했어요." + "value": "예: repo:read, user:email" } } } }, - "Failed to load the list.": { - "comment": "A title for an alert that indicates that the list of secrets failed to load.", - "isCommentAutoGenerated": true, + "e.g root": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to load the list." + "value": "e.g root" } }, "ko": { "stringUnit": { "state": "translated", - "value": "목록을 불러오지 못했어요." + "value": "예: root" } } } }, - "Failed to load.": { - "comment": "Text displayed in the sidebar when loading the list of projects fails.", - "isCommentAutoGenerated": true, + "e.g ssh-rsa AAAA...": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to load." + "value": "e.g ssh-rsa AAAA..." } }, "ko": { "stringUnit": { "state": "translated", - "value": "불러오지 못했어요." + "value": "예: ssh-rsa AAAA..." } } } }, - "Failed to reveal secret.": { + "e.g support@example.com": { + "comment": "Placeholder text for the \"Support Email\" field in the \"License Key\" form section.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to reveal secret." + "value": "e.g support@example.com" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Secret을 확인하지 못했어요." + "value": "예: support@example.com" } } } }, - "Failed to save changes.": { - "comment": "Text displayed in a confirmation alert when an update operation fails.", + "e.g XXXXX-XXXXX-XXXXX-XXXXX": { + "comment": "Placeholder text for a license key field.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to save changes." + "value": "e.g XXXXX-XXXXX-XXXXX-XXXXX" } }, "ko": { "stringUnit": { "state": "translated", - "value": "변경 사항을 저장하지 못했어요." + "value": "예: XXXXX-XXXXX-XXXXX-XXXXX" } } } }, - "Failed to update favorite.": { - "comment": "Text displayed in an alert when updating a favorite status of a secret fails.", + "e.g. github.com": { + "comment": "Label text for the Services field in the CreateSecret form.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to update favorite." + "value": "e.g. github.com" } }, "ko": { "stringUnit": { "state": "translated", - "value": "즐겨찾기 상태를 업데이트하지 못했어요." + "value": "예: github.com" } } } }, - "General": { - "comment": "Title of the \"General\" settings category.", + "Edit": { + "comment": "Accessibility label for the \"Edit\" button in the secret detail header.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "General" + "value": "Edit" } }, "ko": { "stringUnit": { "state": "translated", - "value": "일반" + "value": "편집" } } } }, - "Host": { + "Editing is unavailable until the linked projects load. Other details are unaffected.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Host" + "value": "Editing is unavailable until the linked projects load. Other details are unaffected." } }, "ko": { "stringUnit": { "state": "translated", - "value": "Host" + "value": "연결된 프로젝트를 불러올 때까지 편집할 수 없습니다. 다른 정보에는 영향이 없습니다." } } } }, - "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.", + "Email": { + "comment": "Label for an action that sends feedback to the app's support team.", "isCommentAutoGenerated": true, "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "If Touch ID is unavailable,\nsystem password will be used." - } - }, "ko": { "stringUnit": { "state": "translated", - "value": "Touch ID를 사용할 수 없으면\n시스템 암호를 사용해요." + "value": "이메일" } } } }, - "In progress": { - "comment": "Accessibility label for the full-window overlay shown while a save or decryption is running. Tells assistive technology users that the window is temporarily locked.", + "Enable expiration alerts": { + "comment": "Title of a toggle row that allows the user to enable or disable expiration alerts.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "In progress" + "value": "Enable expiration alerts" } }, "ko": { "stringUnit": { "state": "translated", - "value": "진행 중" + "value": "만료 알림 사용" } } } }, - "Individual": { - "comment": "Description of a license tier when the user is an individual.", - "isCommentAutoGenerated": true, + "Enable Sync": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Individual" + "value": "Enable Sync" } }, "ko": { "stringUnit": { "state": "translated", - "value": "개인" + "value": "동기화 사용" } } } }, - "Keep editing": { + "Enable Touch ID": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Keep editing" + "value": "Enable Touch ID" } }, "ko": { "stringUnit": { "state": "translated", - "value": "계속 편집" + "value": "Touch ID 사용" } } } }, - "Last update detected": { + "Encryption unavailable.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Last update detected" + "value": "Encryption unavailable." } }, "ko": { "stringUnit": { "state": "translated", - "value": "마지막 업데이트 감지됨" + "value": "암호화를 사용할 수 없습니다." } } } }, - "Launch DeVault at login": { - "comment": "Title of a toggle row that allows the user to enable or disable automatic launch of DeVault at login.", + "Enterprise": { + "comment": "Description of a license tier when the user is an enterprise user.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Launch DeVault at login" + "value": "Enterprise" } }, "ko": { "stringUnit": { "state": "translated", - "value": "로그인 시 DeVault 실행" + "value": "기업" } } } }, - "License": { - "comment": "Title of a settings section that links to the project license.", - "isCommentAutoGenerated": true, + "Environment": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "License" + "value": "Environment" } }, "ko": { "stringUnit": { "state": "translated", - "value": "라이선스" + "value": "환경" } } } }, - "License Key": { + "Environment Variable Set": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "License Key" + "value": "Environment Variable Set" } }, "ko": { "stringUnit": { "state": "translated", - "value": "License Key" + "value": "Environment Variable Set" } } } }, - "Link String": { + "envSet List": { + "comment": "Label for the \"envSet List\" field in the form.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Link String" + "value": "envSet List" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Link String" + "value": "envSet 목록" } } } }, - "Lock App": { - "comment": "Accessibility label for the lock button in the MainView.", - "isCommentAutoGenerated": true, + "Etc": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Lock App" + "value": "Etc" } }, "ko": { "stringUnit": { "state": "translated", - "value": "앱 잠금" + "value": "Etc" } } } }, - "Lock after inactivity": { - "comment": "Label for the \"Lock after inactivity\" setting in the Security Settings view.", - "isCommentAutoGenerated": true, + "Excludes the DeVault window from screenshots, screen recordings, and screen sharing.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Lock after inactivity" + "value": "Excludes the DeVault window from screenshots, screen recordings, and screen sharing." } }, "ko": { "stringUnit": { "state": "translated", - "value": "비활성 시 잠금" + "value": "스크린샷, 화면 녹화, 화면 공유에서 DeVault 창을 제외합니다." } } } }, - "MIT License": { - "comment": "Text for the MIT license link in the About settings view.", + "Expand Projects": { + "comment": "Accessibility label for the button that expands the list of projects.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "MIT License" + "value": "Expand Projects" } }, "ko": { "stringUnit": { "state": "translated", - "value": "MIT License" + "value": "프로젝트 펼치기" } } } }, - "Memo": { + "Expiration Alerts": { + "comment": "Title of the section that controls expiration alerts.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Memo" + "value": "Expiration Alerts" } }, "ko": { "stringUnit": { "state": "translated", - "value": "메모" + "value": "만료 알림" } } } }, - "Move to trash?": { - "comment": "Alert title when confirming to delete a secret.", + "Expire Date": { + "comment": "Label for the \"Expire Date\" field in the \"Create Secret\" form.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Move to trash?" + "value": "Expire Date" } }, "ko": { "stringUnit": { "state": "translated", - "value": "휴지통으로 이동할까요?" + "value": "만료일" } } } }, - "Name": { + "Expired": { + "comment": "Tooltip label shown when a secret has already expired.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Name" + "value": "Expired" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이름" + "value": "만료됨" } } } }, - "Network Unavailable.": { + "Expires within %lld days": { + "comment": "A tooltip text for the `SecretExpiryStatus.critical` case.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Network Unavailable." + "value": "Expires within %lld days" } }, "ko": { "stringUnit": { "state": "translated", - "value": "네트워크를 사용할 수 없어요." + "value": "%lld일 이내 만료" } } } }, - "New Secret": { + "Expiry": { + "comment": "A label displayed in the context menu.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Secret" + "value": "Expiry" } }, "ko": { "stringUnit": { "state": "translated", - "value": "새 Secret" + "value": "만료일" } } } }, - "No expiration": { + "Failed to copy.": { + "comment": "Text displayed in a notification when a value could not be copied to the clipboard.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No expiration" + "value": "Failed to copy." } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료 없음" + "value": "복사하지 못했습니다." } } } }, - "No iCloud Account": { + "Failed to delete secret.": { + "comment": "Text displayed in an alert when a secret deletion fails.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No iCloud Account" + "value": "Failed to delete secret." } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud 계정 없음" + "value": "Secret을 삭제하지 못했습니다." } } } }, - "No project selected": { - "comment": "Text displayed when a project is not selected.", - "isCommentAutoGenerated": true, + "Failed to load linked projects": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No project selected" + "value": "Failed to load linked projects" } }, "ko": { "stringUnit": { "state": "translated", - "value": "선택된 프로젝트 없음" + "value": "연결된 프로젝트를 불러오지 못했습니다" } } } }, - "No projects yet": { + "Failed to load projects.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No projects yet" + "value": "Failed to load projects." } }, "ko": { "stringUnit": { "state": "translated", - "value": "아직 프로젝트가 없어요" + "value": "프로젝트를 불러오지 못했습니다." } } } }, - "No secret selected": { + "Failed to load the list.": { + "comment": "A title for an alert that indicates that the list of secrets failed to load.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No secret selected" + "value": "Failed to load the list." } }, "ko": { "stringUnit": { "state": "translated", - "value": "선택된 Secret 없음" + "value": "목록을 불러오지 못했습니다." } } } }, - "No secrets.": { - "comment": "A message displayed when there are no secrets.", + "Failed to load.": { + "comment": "Text displayed in the sidebar when loading the list of projects fails.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No secrets." + "value": "Failed to load." } }, "ko": { "stringUnit": { "state": "translated", - "value": "Secret이 없어요." + "value": "불러오지 못했습니다." } } } }, - "Not Connected": { + "Failed to reveal secret.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Not Connected" + "value": "Failed to reveal secret." } }, "ko": { "stringUnit": { "state": "translated", - "value": "연결되지 않음" + "value": "Secret을 확인하지 못했습니다." } } } }, - "Not Now": { + "Failed to save changes.": { + "comment": "Text displayed in a confirmation alert when an update operation fails.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Not Now" + "value": "Failed to save changes." } }, "ko": { "stringUnit": { "state": "translated", - "value": "나중에" + "value": "변경 사항을 저장하지 못했습니다." } } } }, - "Not Required": { - "comment": "Text indicating that SSL is not required for this database.", + "Failed to update favorite.": { + "comment": "Text displayed in an alert when updating a favorite status of a secret fails.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Not Required" + "value": "Failed to update favorite." } }, "ko": { "stringUnit": { "state": "translated", - "value": "필요 없음" + "value": "즐겨찾기 상태를 업데이트하지 못했습니다." } } } }, - "Notifications": { - "comment": "Title of the \"Notifications\" settings category.", + "Filters": { + "comment": "Title of the menu that allows users to select sidebar filters.", "isCommentAutoGenerated": true, "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Notifications" - } - }, "ko": { "stringUnit": { "state": "translated", - "value": "알림" + "value": "필터" } } } }, - "Notifications are turned off": { - "comment": "Text displayed in a notification banner when system notification permission is required for alerts to appear.", + "General": { + "comment": "Title of the \"General\" settings category.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notifications are turned off" + "value": "General" } }, "ko": { "stringUnit": { "state": "translated", - "value": "알림이 꺼져 있어요" + "value": "일반" } } } }, - "OAuth": { + "Help": { + "comment": "Title of a link in the \"Support\" section of the About settings view that takes them to the help center.", + "isCommentAutoGenerated": true, "localizations": { - "en": { + "ko": { "stringUnit": { "state": "translated", - "value": "OAuth" + "value": "도움말" } - }, + } + } + }, + "Hide": { + "comment": "Accessibility label for hiding text in a `DVTextField`.", + "isCommentAutoGenerated": true, + "localizations": { "ko": { "stringUnit": { "state": "translated", - "value": "OAuth" + "value": "숨기기" } } } }, - "OAuth Client": { - "comment": "Title of the \"OAuth Client\" option in the \"Create Secret\" screen.", - "isCommentAutoGenerated": true, + "Host": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "OAuth Client" + "value": "Host" } }, "ko": { "stringUnit": { "state": "translated", - "value": "OAuth Client" + "value": "Host" } } } }, - "OK": { + "iCloud": { + "comment": "Title of the \"iCloud\" settings category.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "OK" + "value": "iCloud" } }, "ko": { "stringUnit": { "state": "translated", - "value": "확인" + "value": "iCloud" } } } }, - "On the day of expiration": { - "comment": "Label for an option in the \"Expiration Alerts\" settings section that specifies the user should be notified on the exact day of expiration.", - "isCommentAutoGenerated": true, + "iCloud Restricted": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "On the day of expiration" + "value": "iCloud Restricted" } }, "ko": { "stringUnit": { "state": "translated", - "value": "만료 당일" + "value": "iCloud 제한됨" } } } }, - "Open Settings": { - "comment": "Title of the settings option in the \"App Shortcuts\" section.", + "iCloud Sync": { + "comment": "Title of the section in the iCloud Settings view related to iCloud Sync.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Settings" + "value": "iCloud Sync" } }, "ko": { "stringUnit": { "state": "translated", - "value": "설정 열기" + "value": "iCloud 동기화" } } } }, - "Open System Settings": { + "iCloud Sync Enabled!": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open System Settings" + "value": "iCloud Sync Enabled!" } }, "ko": { "stringUnit": { "state": "translated", - "value": "시스템 설정 열기" + "value": "iCloud 동기화가 켜졌습니다!" } } } }, - "Order Number": { - "comment": "Label for the \"Order Number\" field in the \"License Key\" form section.", + "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, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Order Number" + "value": "iCloud sync isn't available right now. Please try again later." } }, "ko": { "stringUnit": { "state": "translated", - "value": "Order Number" + "value": "지금은 iCloud 동기화를 사용할 수 없습니다. 나중에 다시 시도해 주십시오." } } } }, - "PassPhrase": { - "comment": "Label for the passphrase field in the SSH key form section.", + "iCloud sync isn't available.": { + "comment": "Title of an alert when iCloud sync is unavailable.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "PassPhrase" + "value": "iCloud sync isn't available." } }, "ko": { "stringUnit": { "state": "translated", - "value": "PassPhrase" + "value": "iCloud 동기화를 사용할 수 없습니다." } } } }, - "Please authenticate to save the secret.": { + "iCloud Temporarily Unavailable.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Please authenticate to save the secret." + "value": "iCloud Temporarily Unavailable." } }, "ko": { "stringUnit": { "state": "translated", - "value": "Secret을 저장하려면 인증해 주세요." + "value": "iCloud를 일시적으로 사용할 수 없습니다." } } } }, - "Please authenticate to save your changes. Your changes are still here.": { + "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, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Please authenticate to save your changes. Your changes are still here." + "value": "If Touch ID is unavailable,\nsystem password will be used." } }, "ko": { "stringUnit": { "state": "translated", - "value": "변경 사항을 저장하려면 인증해 주세요. 변경 사항은 그대로 남아 있어요." + "value": "Touch ID를 사용할 수 없으면\n시스템 암호를 사용합니다." } } } }, - "Please authenticate to view the secret.": { - "comment": "Alert message when authentication is required to view a secret.", - "isCommentAutoGenerated": true, + "In progress": { + "comment": "Accessibility label for the full-window overlay shown while a save or decryption is running. Tells assistive technology users that the window is temporarily locked.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Please authenticate to view the secret." + "value": "In progress" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Secret을 보려면 인증해 주세요." + "value": "진행 중" } } } }, - "Please enter a different name.": { - "comment": "Alert message asking the user to pick a different project name.", + "Individual": { + "comment": "Description of a license tier when the user is an individual.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Please enter a different name." + "value": "Individual" } }, "ko": { "stringUnit": { "state": "translated", - "value": "다른 이름을 입력해 주세요." + "value": "개인" } } } }, - "Please enter a different project name.": { - "comment": "Alert message asking the user to pick a different project name.", + "Keep editing": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Please enter a different project name." + "value": "Keep editing" } }, "ko": { "stringUnit": { "state": "translated", - "value": "다른 프로젝트 이름을 입력해 주세요." + "value": "계속 편집" } } } }, - "Please enter a name.": { - "comment": "Alert title when a user tries to rename a project with an empty name.", + "Last update detected": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Please enter a name." + "value": "Last update detected" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이름을 입력해 주세요." + "value": "마지막 업데이트 감지됨" } } } }, - "Please try again in a moment.": { + "Launch DeVault at login": { + "comment": "Title of a toggle row that allows the user to enable or disable automatic launch of DeVault at login.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Please try again in a moment." + "value": "Launch DeVault at login" } }, "ko": { "stringUnit": { "state": "translated", - "value": "잠시 후 다시 시도해 주세요." + "value": "로그인 시 DeVault 실행" } } } }, - "Please try again.": { - "comment": "Text for an alert that appears when the user can try again after a failure.", + "License": { + "comment": "Title of a settings section that links to the project license.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Please try again." + "value": "License" } }, "ko": { "stringUnit": { "state": "translated", - "value": "다시 시도해 주세요." + "value": "라이선스" } } } }, - "Private Key": { + "License Key": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Private Key" + "value": "License Key" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Private Key" + "value": "License Key" } } } }, - "Production": { + "Light": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Production" + "value": "Light" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Production" + "value": "라이트" } } } }, - "Project": { + "Link String": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Project" + "value": "Link String" } }, "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트" + "value": "Link String" } } } }, - "Project Name": { + "Lock after inactivity": { + "comment": "Label for the \"Lock after inactivity\" setting in the Security Settings view.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Project Name" + "value": "Lock after inactivity" } }, "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트 이름" + "value": "비활성 시 잠금" } } } }, - "Project information could not be loaded. Other details are unaffected.": { - "comment": "Alert message when project details cannot be loaded.", + "Lock App": { + "comment": "Accessibility label for the lock button in the MainView.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Project information could not be loaded. Other details are unaffected." + "value": "Lock App" } }, "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트 정보를 불러올 수 없어요. 다른 정보에는 영향이 없어요." + "value": "앱 잠금" } } } }, - "Project name can't be empty.": { - "comment": "Alert message when a user tries to rename a project with an empty name.", + "Lock DeVault": { + "comment": "Title of the menu item that locks the vault.", + "isCommentAutoGenerated": true, "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Project name can't be empty." - } - }, "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트 이름은 비워둘 수 없어요." + "value": "DeVault 잠금" } } } }, - "Protect the entire app window": { + "Memo": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Protect the entire app window" + "value": "Memo" } }, "ko": { "stringUnit": { "state": "translated", - "value": "앱 창 전체 보호" + "value": "메모" } } } }, - "Public Key": { + "MIT License": { + "comment": "Text for the MIT license link in the About settings view.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Public Key" + "value": "MIT License" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Public Key" + "value": "MIT License" } } } }, - "Recover": { + "Move to trash?": { + "comment": "Alert title when confirming to delete a secret.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Recover" + "value": "Move to trash?" } }, "ko": { "stringUnit": { "state": "translated", - "value": "복구" + "value": "휴지통으로 이동하시겠습니까?" } } } }, - "Redirect URL": { + "Name": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Redirect URL" + "value": "Name" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Redirect URL" + "value": "이름" } } } }, - "Refresh Status": { + "Network Unavailable.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Refresh Status" + "value": "Network Unavailable." } }, "ko": { "stringUnit": { "state": "translated", - "value": "상태 새로고침" + "value": "네트워크를 사용할 수 없습니다." } } } }, - "Remove from favorites": { - "comment": "Label for a button that removes a secret from the user's favorites.", + "New": { + "comment": "Title of the \"New\" menu item in the macOS app menu.", "isCommentAutoGenerated": true, "localizations": { - "en": { + "ko": { "stringUnit": { "state": "translated", - "value": "Remove from favorites" + "value": "새로 만들기" } - }, + } + } + }, + "New Project": { + "comment": "Title for the \"New Project\" menu command.", + "isCommentAutoGenerated": true, + "localizations": { "ko": { "stringUnit": { "state": "translated", - "value": "즐겨찾기에서 제거" + "value": "새 프로젝트" } } } }, - "Rename": { + "New Secret": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rename" + "value": "New Secret" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이름 변경" + "value": "새 Secret" } } } }, - "Renew Command": { + "No expiration": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Renew Command" + "value": "No expiration" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Renew Command" + "value": "만료 없음" } } } }, - "Require authentication on app launch": { - "comment": "Title of a toggle row that allows the user to enable or disable requiring authentication on app launch.", - "isCommentAutoGenerated": true, + "No iCloud Account": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Require authentication on app launch" + "value": "No iCloud Account" } }, "ko": { "stringUnit": { "state": "translated", - "value": "앱 실행 시 인증 요구" + "value": "iCloud 계정 없음" } } } }, - "Require authentication to copy secret": { - "comment": "Title of a toggle row in the \"Security\" settings section that allows the user to toggle whether they need to authenticate to copy a secret.", + "No project selected": { + "comment": "Text displayed when a project is not selected.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Require authentication to copy secret" + "value": "No project selected" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Secret 복사 시 인증 요구" + "value": "선택된 프로젝트 없음" } } } }, - "Required.": { - "comment": "Validation error message when a required field is empty.", - "isCommentAutoGenerated": true, + "No projects yet": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Required." + "value": "No projects yet" } }, "ko": { "stringUnit": { "state": "translated", - "value": "필수 항목이에요." + "value": "아직 프로젝트가 없습니다" } } } }, - "Reset": { - "comment": "Title of the settings section that allows the user to reset all data.", - "isCommentAutoGenerated": true, + "No secret selected": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reset" + "value": "No secret selected" } }, "ko": { "stringUnit": { "state": "translated", - "value": "초기화" + "value": "선택된 Secret 없음" } } } }, - "Retry": { - "comment": "Button title that triggers the retry action for revealing a secret.", + "No secrets.": { + "comment": "A message displayed when there are no secrets.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Retry" + "value": "No secrets." } }, "ko": { "stringUnit": { "state": "translated", - "value": "다시 시도" + "value": "Secret이 없습니다." } } } }, - "SSH & Credentials": { + "Not Connected": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "SSH & Credentials" + "value": "Not Connected" } }, "ko": { "stringUnit": { "state": "translated", - "value": "SSH & Credentials" + "value": "연결되지 않음" } } } }, - "SSH Key": { + "Not Now": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "SSH Key" + "value": "Not Now" } }, "ko": { "stringUnit": { "state": "translated", - "value": "SSH Key" + "value": "나중에" } } } }, - "SSL Required": { + "Not Required": { + "comment": "Text indicating that SSL is not required for this database.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "SSL Required" + "value": "Not Required" } }, "ko": { "stringUnit": { "state": "translated", - "value": "SSL 필요" + "value": "필요 없음" } } } }, - "SSL/TLS Certificate": { + "Notice": { + "comment": "Title of the \"Notice\" tab in the Secret List.", + "isCommentAutoGenerated": true, "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SSL/TLS Certificate" - } - }, "ko": { "stringUnit": { "state": "translated", - "value": "SSL/TLS Certificate" + "value": "임박" } } } }, - "Sample": { - "comment": "Label for a text field.", + "Notifications": { + "comment": "Title of the \"Notifications\" settings category.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sample" + "value": "Notifications" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Sample" + "value": "알림" } } } }, - "Save": { - "comment": "The label of a button that saves the current input.", + "Notifications are turned off": { + "comment": "Text displayed in a notification banner when system notification permission is required for alerts to appear.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Save" + "value": "Notifications are turned off" } }, "ko": { "stringUnit": { "state": "translated", - "value": "저장" + "value": "알림이 꺼져 있습니다" } } } }, - "Save failed": { + "OAuth": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Save failed" + "value": "OAuth" } }, "ko": { "stringUnit": { "state": "translated", - "value": "저장 실패" + "value": "OAuth" } } } }, - "Scope": { - "localizations": { + "OAuth Client": { + "comment": "Title of the \"OAuth Client\" option in the \"Create Secret\" screen.", + "isCommentAutoGenerated": true, + "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Scope" + "value": "OAuth Client" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Scope" + "value": "OAuth Client" } } } }, - "Screen Protection": { - "comment": "Section title in the Security Settings view for screen protection settings.", - "isCommentAutoGenerated": true, + "OK": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Screen Protection" + "value": "OK" } }, "ko": { "stringUnit": { "state": "translated", - "value": "화면 보호" + "value": "확인" } } } }, - "Search": { - "comment": "Placeholder text for the secret list search field.", + "On the day of expiration": { + "comment": "Label for an option in the \"Expiration Alerts\" settings section that specifies the user should be notified on the exact day of expiration.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Search" + "value": "On the day of expiration" } }, "ko": { "stringUnit": { "state": "translated", - "value": "검색" + "value": "만료 당일" } } } }, - "Security": { - "comment": "Title of the \"Security\" settings category.", + "Open Settings": { + "comment": "Title of the settings option in the \"App Shortcuts\" section.", + "extractionState": "stale", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Security" + "value": "Open Settings" } }, "ko": { "stringUnit": { "state": "translated", - "value": "보안" + "value": "설정 열기" } } } }, - "Security Alerts": { - "comment": "Title of a settings section in the \"Notifications\" tab that contains options for enabling or disabling security alerts.", + "Open Source Licenses": { + "comment": "Title of a section that lists open source licenses used in the app.", "isCommentAutoGenerated": true, "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Security Alerts" - } - }, "ko": { "stringUnit": { "state": "translated", - "value": "보안 알림" + "value": "오픈소스 라이선스" } } } }, - "Select Project": { + "Open System Settings": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Select Project" + "value": "Open System Settings" } }, "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트 선택" + "value": "시스템 설정 열기" } } } }, - "Service Account": { + "optional": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Service Account" + "value": "optional" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Service Account" + "value": "선택" } } } }, - "Services": { - "comment": "Label text for the \"Services\" field in the CreateSecret form.", + "Order Number": { + "comment": "Label for the \"Order Number\" field in the \"License Key\" form section.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Services" + "value": "Order Number" } }, "ko": { "stringUnit": { "state": "translated", - "value": "서비스" + "value": "Order Number" } } } }, - "Settings": { + "PassPhrase": { + "comment": "Label for the passphrase field in the SSH key form section.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Settings" + "value": "PassPhrase" } }, "ko": { "stringUnit": { "state": "translated", - "value": "설정" + "value": "PassPhrase" } } } }, - "Share": { - "comment": "Label for the \"Share\" action in the secret detail view.", - "isCommentAutoGenerated": true, + "Please authenticate to save the secret.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Share" + "value": "Please authenticate to save the secret." } }, "ko": { "stringUnit": { "state": "translated", - "value": "공유" + "value": "Secret을 저장하려면 인증해 주십시오." } } } }, - "Shortcuts": { - "comment": "Title of the settings category related to keyboard shortcuts.", - "isCommentAutoGenerated": true, + "Please authenticate to save your changes. Your changes are still here.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Shortcuts" + "value": "Please authenticate to save your changes. Your changes are still here." } }, "ko": { "stringUnit": { "state": "translated", - "value": "단축키" + "value": "변경 사항을 저장하려면 인증해 주십시오. 변경 사항은 그대로 남아 있습니다." } } } }, - "Sign in to iCloud in System Settings, then try again.": { + "Please authenticate to view the secret.": { + "comment": "Alert message when authentication is required to view a secret.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in to iCloud in System Settings, then try again." + "value": "Please authenticate to view the secret." } }, "ko": { "stringUnit": { "state": "translated", - "value": "시스템 설정에서 iCloud에 로그인한 뒤 다시 시도해 주세요." + "value": "Secret을 보려면 인증해 주십시오." } } } }, - "Sort by": { - "comment": "A label for a picker that lets the user sort the list of secrets.", - "isCommentAutoGenerated": true, + "Please enter a different name.": { + "comment": "Alert message asking the user to pick a different project name.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sort by" + "value": "Please enter a different name." } }, "ko": { "stringUnit": { "state": "translated", - "value": "정렬 기준" + "value": "다른 이름을 입력해 주십시오." } } } }, - "Staging": { + "Please enter a different project name.": { + "comment": "Alert message asking the user to pick a different project name.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Staging" + "value": "Please enter a different project name." } }, "ko": { "stringUnit": { "state": "translated", - "value": "Staging" + "value": "다른 프로젝트 이름을 입력해 주십시오." } } } }, - "Start": { + "Please enter a name.": { + "comment": "Alert title when a user tries to rename a project with an empty name.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Start" + "value": "Please enter a name." } }, "ko": { "stringUnit": { "state": "translated", - "value": "시작" + "value": "이름을 입력해 주십시오." } } } }, - "Startup": { - "comment": "Section title in the General Settings view related to startup preferences.", - "isCommentAutoGenerated": true, + "Please try again in a moment.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Startup" + "value": "Please try again in a moment." } }, "ko": { "stringUnit": { "state": "translated", - "value": "시작 항목" + "value": "잠시 후 다시 시도해 주십시오." } } } }, - "Status": { - "comment": "Title of the section that displays the status of iCloud Sync.", + "Please try again.": { + "comment": "Text for an alert that appears when the user can try again after a failure.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Status" + "value": "Please try again." } }, "ko": { "stringUnit": { "state": "translated", - "value": "상태" + "value": "다시 시도해 주십시오." } } } }, - "Status Unavailable.": { + "Privacy Policy": { "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Status Unavailable." - } - }, "ko": { "stringUnit": { "state": "translated", - "value": "상태를 확인할 수 없어요." + "value": "개인정보처리방침" } } } }, - "Storage Configuration Failed": { + "Private Key": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Storage Configuration Failed" + "value": "Private Key" } }, "ko": { "stringUnit": { "state": "translated", - "value": "저장소 설정 실패" + "value": "Private Key" } } } }, - "Support Email": { + "Production": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Support Email" + "value": "Production" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Support Email" + "value": "Production" } } } }, - "Sync your secrets with iCloud?": { + "Project": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sync your secrets with iCloud?" + "value": "Project" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Secret을 iCloud와 동기화할까요?" + "value": "프로젝트" } } } }, - "System notification permission is required for these alerts to appear.": { - "comment": "Text displayed below the main text in the permission banner, explaining that system notification permission is required for the alerts to appear.", + "Project information could not be loaded. Other details are unaffected.": { + "comment": "Alert message when project details cannot be loaded.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "System notification permission is required for these alerts to appear." + "value": "Project information could not be loaded. Other details are unaffected." } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 알림이 표시되려면 시스템 알림 권한이 필요해요." + "value": "프로젝트 정보를 불러올 수 없습니다. 다른 정보에는 영향이 없습니다." } } } }, - "Team": { + "Project Name": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Team" + "value": "Project Name" } }, "ko": { "stringUnit": { "state": "translated", - "value": "팀" + "value": "프로젝트 이름" } } } }, - "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, + "Project name can't be empty.": { + "comment": "Alert message when a user tries to rename a project with an empty name.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The copied value was cleared after being on the clipboard for over %lld seconds." + "value": "Project name can't be empty." } }, "ko": { "stringUnit": { "state": "translated", - "value": "복사된 값이 클립보드에 %lld초 넘게 남아있어 지워졌어요." + "value": "프로젝트 이름은 비워둘 수 없습니다." } } } }, - "The project list couldn't be loaded. Please try again later.": { + "Protect the entire app window": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The project list couldn't be loaded. Please try again later." + "value": "Protect the entire app window" } }, "ko": { "stringUnit": { "state": "translated", - "value": "프로젝트 목록을 불러오지 못했어요. 나중에 다시 시도해 주세요." + "value": "앱 창 전체 보호" } } } }, - "The secret could not be decrypted. Check that your device passcode is enabled.": { + "Public Key": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The secret could not be decrypted. Check that your device passcode is enabled." + "value": "Public Key" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Secret을 복호화할 수 없어요. 기기 암호가 설정되어 있는지 확인하세요." + "value": "Public Key" } } } }, - "The secret could not be saved because encryption is unavailable. Check that your device passcode is enabled.": { + "Recover": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The secret could not be saved because encryption is unavailable. Check that your device passcode is enabled." + "value": "Recover" } }, "ko": { "stringUnit": { "state": "translated", - "value": "암호화를 사용할 수 없어 Secret을 저장하지 못했어요. 기기 암호가 설정되어 있는지 확인하세요." + "value": "복구" } } } }, - "The setting was saved, but existing notifications couldn't be updated. Please try again.": { + "Redirect URL": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The setting was saved, but existing notifications couldn't be updated. Please try again." + "value": "Redirect URL" } }, "ko": { "stringUnit": { "state": "translated", - "value": "설정은 저장됐지만 기존 알림을 업데이트하지 못했어요. 다시 시도해 주세요." + "value": "Redirect URL" } } } }, - "The value could not be copied to the clipboard. Please try again.": { - "comment": "Alert message when a clipboard copy operation fails.", - "isCommentAutoGenerated": true, + "Refresh Status": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The value could not be copied to the clipboard. Please try again." + "value": "Refresh Status" } }, "ko": { "stringUnit": { "state": "translated", - "value": "값을 클립보드에 복사하지 못했어요. 다시 시도해 주세요." + "value": "상태 새로고침" } } } }, - "This action cannot be undone.": { - "comment": "Warning text displayed above the \"Delete All Data\" button in the Data Settings view.", + "Remove from favorites": { + "comment": "Label for a button that removes a secret from the user's favorites.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This action cannot be undone." + "value": "Remove from favorites" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 작업은 되돌릴 수 없어요." + "value": "즐겨찾기에서 제거" } } } }, - "This name is already in use.": { - "comment": "Alert title when a project rename target name is already taken.", + "Rename": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This name is already in use." + "value": "Rename" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이미 사용 중인 이름이에요." + "value": "이름 변경" } } } }, - "This project name is already in use.": { - "comment": "Alert title when a new project's name is already taken.", + "Renew Command": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This project name is already in use." + "value": "Renew Command" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이미 사용 중인 프로젝트 이름이에요." + "value": "Renew Command" } } } }, - "This will also delete data from iCloud and all synced devices.": { - "comment": "Warning shown in Data Settings when iCloud Sync is enabled.", + "Require authentication on app launch": { + "comment": "Title of a toggle row that allows the user to enable or disable requiring authentication on app launch.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will also delete data from iCloud and all synced devices." + "value": "Require authentication on app launch" } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud와 동기화된 모든 기기의 데이터도 함께 삭제돼요." + "value": "앱 실행 시 인증 요구" } } } }, - "This will permanently delete all secrets and projects. This action cannot be undone.": { - "comment": "Message displayed in an alert when the user confirms deleting all data.", + "Require authentication to copy secret": { + "comment": "Title of a toggle row in the \"Security\" settings section that allows the user to toggle whether they need to authenticate to copy a secret.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will permanently delete all secrets and projects. This action cannot be undone." + "value": "Require authentication to copy secret" } }, "ko": { "stringUnit": { "state": "translated", - "value": "모든 Secret과 프로젝트가 영구적으로 삭제돼요. 이 작업은 되돌릴 수 없어요." + "value": "Secret 복사 시 인증 요구" } } } }, - "Time": { - "comment": "A label for sorting by time.", + "Required.": { + "comment": "Validation error message when a required field is empty.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Time" + "value": "Required." } }, "ko": { "stringUnit": { "state": "translated", - "value": "시간" + "value": "필수 항목입니다." } } } }, - "Try Again": { - "comment": "Label for a button that triggers the action of retrying an operation.", + "Reset": { + "comment": "Title of the settings section that allows the user to reset all data.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Try Again" + "value": "Reset" } }, "ko": { "stringUnit": { "state": "translated", - "value": "다시 시도" + "value": "초기화" } } } }, - "Turn Off": { + "Retry": { + "comment": "Button title that triggers the retry action for revealing a secret.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Turn Off" + "value": "Retry" } }, "ko": { "stringUnit": { "state": "translated", - "value": "끄기" + "value": "다시 시도" } } } }, - "Turn Off iCloud Sync?": { + "Reveal": { + "comment": "Label for a button that reveals the content of a field.", + "isCommentAutoGenerated": true, "localizations": { - "en": { + "ko": { "stringUnit": { "state": "translated", - "value": "Turn Off iCloud Sync?" + "value": "표시" } - }, + } + } + }, + "Sample": { + "comment": "Label for a text field.", + "extractionState": "stale", + "isCommentAutoGenerated": true, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sample" + } + }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud 동기화를 끌까요?" + "value": "Sample" } } } }, - "Turn on iCloud Sync to sync secrets across devices.": { - "comment": "Text displayed in the body of the status card when iCloud Sync is disabled.", + "Save": { + "comment": "The label of a button that saves the current input.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Turn on iCloud Sync to sync secrets across devices." + "value": "Save" } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud 동기화를 켜면 여러 기기에서 Secret을 동기화할 수 있어요." + "value": "저장" } } } }, - "Type": { - "comment": "Label text for the \"Type\" field in the CreateSecret form.", + "Save failed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save failed" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장 실패" + } + } + } + }, + "Scope": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scope" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Scope" + } + } + } + }, + "Screen Protection": { + "comment": "Section title in the Security Settings view for screen protection settings.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Type" + "value": "Screen Protection" } }, "ko": { "stringUnit": { "state": "translated", - "value": "유형" + "value": "화면 보호" } } } }, - "Unlock failed": { + "Search": { + "comment": "Placeholder text for the secret list search field.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Unlock failed" + "value": "Search" } }, "ko": { "stringUnit": { "state": "translated", - "value": "잠금 해제 실패" + "value": "검색" } } } }, - "Unlock with Touch ID": { - "comment": "Button title that unlocks the app using Touch ID.", + "Security": { + "comment": "Title of the \"Security\" settings category.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Unlock with Touch ID" + "value": "Security" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Touch ID로 잠금 해제" + "value": "보안" } } } }, - "Use iCloud Sync": { - "comment": "Title of a toggle row that allows the user to enable or disable iCloud Sync.", + "Security Alerts": { + "comment": "Title of a settings section in the \"Notifications\" tab that contains options for enabling or disabling security alerts.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Use iCloud Sync" + "value": "Security Alerts" } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud 동기화 사용" + "value": "보안 알림" } } } }, - "Username": { + "Select Project": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Username" + "value": "Select Project" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Username" + "value": "프로젝트 선택" } } } }, - "Value": { + "Send Feedback": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "피드백 보내기" + } + } + } + }, + "Service Account": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Value" + "value": "Service Account" } }, "ko": { "stringUnit": { "state": "translated", - "value": "값" + "value": "Service Account" } } } }, - "Vault": { - "shouldTranslate": false + "Services": { + "comment": "Label text for the \"Services\" field in the CreateSecret form.", + "isCommentAutoGenerated": true, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Services" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서비스" + } + } + } }, - "Version": { - "comment": "Title of a section in the \"About\" settings view that displays the current version of the app.", + "Settings": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정" + } + } + } + }, + "Settings…": { + "comment": "Text for the \"Settings…\" option in the macOS App menu.", + "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정…" + } + } + } + }, + "Share": { + "comment": "Label for the \"Share\" action in the secret detail view.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Version" + "value": "Share" } }, "ko": { "stringUnit": { "state": "translated", - "value": "버전" + "value": "공유" } } } }, - "View on GitHub": { - "comment": "Text for a link that takes the user to the license file on GitHub.", + "Shortcuts": { + "comment": "Title of the settings category related to keyboard shortcuts.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "View on GitHub" + "value": "Shortcuts" } }, "ko": { "stringUnit": { "state": "translated", - "value": "GitHub에서 보기" + "value": "단축키" } } } }, - "Website": { + "Show": { + "comment": "Accessibility label for showing text in a `LabeledTextFieldView`.", + "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시" + } + } + } + }, + "Sign in to iCloud in System Settings, then try again.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Website" + "value": "Sign in to iCloud in System Settings, then try again." } }, "ko": { "stringUnit": { "state": "translated", - "value": "웹사이트" + "value": "시스템 설정에서 iCloud에 로그인한 뒤 다시 시도해 주십시오." } } } }, - "When enabled, data on this Mac is merged with iCloud. Turning it off keeps data in both places and stops future sync.": { + "Sort": { + "comment": "Label for a button that sorts the list of secrets.", + "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "정렬" + } + } + } + }, + "Sort by": { + "comment": "A label for a picker that lets the user sort the list of secrets.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "When enabled, data on this Mac is merged with iCloud. Turning it off keeps data in both places and stops future sync." + "value": "Sort by" } }, "ko": { "stringUnit": { "state": "translated", - "value": "켜면 이 Mac의 데이터가 iCloud와 병합돼요. 끄면 양쪽에 데이터가 유지되고 이후 동기화가 중단돼요." + "value": "정렬 기준" } } } }, - "You can change this anytime in Settings.": { + "SSH & Credentials": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You can change this anytime in Settings." + "value": "SSH & Credentials" } }, "ko": { "stringUnit": { "state": "translated", - "value": "설정에서 언제든지 변경할 수 있어요." + "value": "SSH & Credentials" } } } }, - "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.", + "SSH Key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH Key" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SSH Key" + } + } + } + }, + "SSL Required": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSL Required" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SSL 필요" + } + } + } + }, + "SSL/TLS Certificate": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSL/TLS Certificate" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SSL/TLS Certificate" + } + } + } + }, + "Staging": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Staging" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Staging" + } + } + } + }, + "Star": { + "comment": "Text for the \"Star\" tab in the Secret List.", "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기" + } + } + } + }, + "Start": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You can restore it later from Trash." + "value": "Start" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작" + } + } + } + }, + "Startup": { + "comment": "Section title in the General Settings view related to startup preferences.", + "isCommentAutoGenerated": true, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Startup" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작 항목" + } + } + } + }, + "Status": { + "comment": "Title of the section that displays the status of iCloud Sync.", + "isCommentAutoGenerated": true, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태" + } + } + } + }, + "Status Unavailable.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status Unavailable." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태를 확인할 수 없습니다." + } + } + } + }, + "Storage Configuration Failed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Storage Configuration Failed" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장소 설정 실패" + } + } + } + }, + "Support": { + "comment": "Section title that links to the developer support options.", + "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원" + } + } + } + }, + "Support Center": { + "comment": "Link title for the \"Help\" section that navigates to the support center.", + "isCommentAutoGenerated": true, + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원 센터" + } + } + } + }, + "Support Email": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Support Email" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Support Email" + } + } + } + }, + "Sync your secrets with iCloud?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sync your secrets with iCloud?" } }, "ko": { "stringUnit": { "state": "translated", - "value": "나중에 휴지통에서 복구할 수 있어요." + "value": "Secret을 iCloud와 동기화하시겠습니까?" } } } }, - "Your secrets are protected with Touch ID.": { + "System": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your secrets are protected with Touch ID." + "value": "System" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Touch ID로 Secret이 보호되고 있어요." + "value": "시스템 설정" } } } }, - "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.", + "System notification permission is required for these alerts to appear.": { + "comment": "Text displayed below the main text in the permission banner, explaining that system notification permission is required for the alerts to appear.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your secrets will sync automatically when a connection is available." + "value": "System notification permission is required for these alerts to appear." } }, "ko": { "stringUnit": { "state": "translated", - "value": "연결이 가능해지면 Secret이 자동으로 동기화돼요." + "value": "이 알림이 표시되려면 시스템 알림 권한이 필요합니다." } } } }, - "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, + "Team": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your unsaved changes will be lost." + "value": "Team" } }, "ko": { "stringUnit": { "state": "translated", - "value": "저장하지 않은 변경 사항이 사라져요." + "value": "팀" } } } }, - "e.g -----BEGIN CERTIFICATE-----": { + "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, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g -----BEGIN CERTIFICATE-----" + "value": "The copied value was cleared after being on the clipboard for over %lld seconds." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: -----BEGIN CERTIFICATE-----" + "value": "복사된 값이 클립보드에 %lld초 넘게 남아 있어 지워졌습니다." } } } }, - "e.g -----BEGIN OPENSSH PRIVATE KEY-----": { + "The project list couldn't be loaded. Please try again later.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g -----BEGIN OPENSSH PRIVATE KEY-----" + "value": "The project list couldn't be loaded. Please try again later." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: -----BEGIN OPENSSH PRIVATE KEY-----" + "value": "프로젝트 목록을 불러오지 못했습니다. 나중에 다시 시도해 주십시오." } } } }, - "e.g -----BEGIN PRIVATE KEY-----": { - "comment": "Placeholder text for the private key field in the SSL/TLS certificate form section.", - "isCommentAutoGenerated": true, + "The secret could not be decrypted. Check that your device passcode is enabled.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g -----BEGIN PRIVATE KEY-----" + "value": "The secret could not be decrypted. Check that your device passcode is enabled." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: -----BEGIN PRIVATE KEY-----" + "value": "Secret을 복호화할 수 없습니다. 기기 암호가 설정되어 있는지 확인해 주십시오." } } } }, - "e.g DeVault": { - "comment": "Placeholder text for the Name field in the CreateSecret form.", - "isCommentAutoGenerated": true, + "The secret could not be saved because encryption is unavailable. Check that your device passcode is enabled.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g DeVault" + "value": "The secret could not be saved because encryption is unavailable. Check that your device passcode is enabled." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: DeVault" + "value": "암호화를 사용할 수 없어 Secret을 저장하지 못했습니다. 기기 암호가 설정되어 있는지 확인해 주십시오." } } } }, - "e.g FOO=bar": { - "comment": "Placeholder text for the \"envSet List\" text field in the form.", - "isCommentAutoGenerated": true, + "The setting was saved, but existing notifications couldn't be updated. Please try again.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g FOO=bar" + "value": "The setting was saved, but existing notifications couldn't be updated. Please try again." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: FOO=bar" + "value": "설정은 저장됐지만 기존 알림을 업데이트하지 못했습니다. 다시 시도해 주십시오." } } } }, - "e.g ORD-2026-0001": { + "The value could not be copied to the clipboard. Please try again.": { + "comment": "Alert message when a clipboard copy operation fails.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g ORD-2026-0001" + "value": "The value could not be copied to the clipboard. Please try again." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: ORD-2026-0001" + "value": "값을 클립보드에 복사하지 못했습니다. 다시 시도해 주십시오." } } } }, - "e.g XXXXX-XXXXX-XXXXX-XXXXX": { - "comment": "Placeholder text for a license key field.", - "isCommentAutoGenerated": true, + "Theme": { + "comment": "Title of the appearance/theme picker in the General Settings view.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g XXXXX-XXXXX-XXXXX-XXXXX" + "value": "Theme" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: XXXXX-XXXXX-XXXXX-XXXXX" + "value": "테마" } } } }, - "e.g abc123secret": { - "comment": "Placeholder text for the \"Client Secret\" text field in the \"OAuth Client\" form section.", + "This action cannot be undone.": { + "comment": "Warning text displayed above the \"Delete All Data\" button in the Data Settings view.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g abc123secret" + "value": "This action cannot be undone." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: abc123secret" + "value": "이 작업은 되돌릴 수 없습니다." } } } }, - "e.g certbot renew": { + "This name is already in use.": { + "comment": "Alert title when a project rename target name is already taken.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g certbot renew" + "value": "This name is already in use." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: certbot renew" + "value": "이미 사용 중인 이름입니다." } } } }, - "e.g custom-secret-value": { - "comment": "Placeholder text for the value field in the custom secret form.", - "isCommentAutoGenerated": true, + "This project name is already in use.": { + "comment": "Alert title when a new project's name is already taken.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g custom-secret-value" + "value": "This project name is already in use." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: custom-secret-value" + "value": "이미 사용 중인 프로젝트 이름입니다." } } } }, - "e.g deploy.example.com": { - "comment": "Placeholder text for a label.", + "This will also delete data from iCloud and all synced devices.": { + "comment": "Warning shown in Data Settings when iCloud Sync is enabled.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g deploy.example.com" + "value": "This will also delete data from iCloud and all synced devices." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: deploy.example.com" + "value": "iCloud와 동기화된 모든 기기의 데이터도 함께 삭제됩니다." } } } }, - "e.g example.com": { + "This will permanently delete all secrets and projects. This action cannot be undone.": { + "comment": "Message displayed in an alert when the user confirms deleting all data.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g example.com" + "value": "This will permanently delete all secrets and projects. This action cannot be undone." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: example.com" + "value": "모든 Secret과 프로젝트가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다." } } } }, - "e.g ghp_1234567890": { + "Time": { + "comment": "A label for sorting by time.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g ghp_1234567890" + "value": "Time" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: ghp_1234567890" + "value": "시간" } } } }, - "e.g https://app.example/oauth/callback": { + "Try Again": { + "comment": "Label for a button that triggers the action of retrying an operation.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g https://app.example/oauth/callback" + "value": "Try Again" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: https://app.example/oauth/callback" + "value": "다시 시도" } } } }, - "e.g https://example.com": { + "Turn Off": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g https://example.com" + "value": "Turn Off" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: https://example.com" + "value": "끄기" } } } }, - "e.g my-app-client": { + "Turn Off iCloud Sync?": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g my-app-client" + "value": "Turn Off iCloud Sync?" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: my-app-client" + "value": "iCloud 동기화를 끄시겠습니까?" } } } }, - "e.g organization-admin": { - "comment": "Placeholder text for the \"Authority / Scope\" text field in the `ServiceAccountSectionView`.", + "Turn on iCloud Sync to sync secrets across devices.": { + "comment": "Text displayed in the body of the status card when iCloud Sync is disabled.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g organization-admin" + "value": "Turn on iCloud Sync to sync secrets across devices." } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: organization-admin" + "value": "iCloud 동기화를 켜면 여러 기기에서 Secret을 동기화할 수 있습니다." } } } }, - "e.g postgres://user:pass@host:5432/db": { - "comment": "Placeholder text for the \"Link String\" field in the \"Database\" form section.", + "Type": { + "comment": "Label text for the \"Type\" field in the CreateSecret form.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g postgres://user:pass@host:5432/db" + "value": "Type" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: postgres://user:pass@host:5432/db" + "value": "유형" } } } }, - "e.g read:user, write:issue": { + "Unlock failed": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g read:user, write:issue" + "value": "Unlock failed" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: read:user, write:issue" + "value": "잠금 해제 실패" } } } }, - "e.g repo:read, user:email": { + "Unlock with Touch ID": { + "comment": "Button title that unlocks the app using Touch ID.", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g repo:read, user:email" + "value": "Unlock with Touch ID" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: repo:read, user:email" + "value": "Touch ID로 잠금 해제" } } } }, - "e.g root": { + "Use iCloud Sync": { + "comment": "Title of a toggle row that allows the user to enable or disable iCloud Sync.", + "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g root" + "value": "Use iCloud Sync" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: root" + "value": "iCloud 동기화 사용" } } } }, - "e.g ssh-rsa AAAA...": { + "Username": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g ssh-rsa AAAA..." + "value": "Username" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: ssh-rsa AAAA..." + "value": "Username" } } } }, - "e.g support@example.com": { - "comment": "Placeholder text for the \"Support Email\" field in the \"License Key\" form section.", - "isCommentAutoGenerated": true, + "Value": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g support@example.com" + "value": "Value" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: support@example.com" + "value": "값" } } } }, - "e.g {\"type\": \"service_account\", ...}": { + "Vault": { + "shouldTranslate": false, "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "e.g {\"type\": \"service_account\", ...}" - } - }, "ko": { "stringUnit": { "state": "translated", - "value": "예: {\"type\": \"service_account\", ...}" + "value": "Vault" } } } }, - "e.g. github.com": { - "comment": "Label text for the Services field in the CreateSecret form.", + "Version": { + "comment": "Title of a section in the \"About\" settings view that displays the current version of the app.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g. github.com" + "value": "Version" } }, "ko": { "stringUnit": { "state": "translated", - "value": "예: github.com" + "value": "버전" } } } }, - "envSet List": { - "comment": "Label for the \"envSet List\" field in the form.", + "View": { + "comment": "Text for a button that links to view more information.", "isCommentAutoGenerated": true, "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "envSet List" - } - }, "ko": { "stringUnit": { "state": "translated", - "value": "envSet 목록" + "value": "보기" } } } }, - "iCloud": { - "comment": "Title of the \"iCloud\" settings category.", + "View on GitHub": { + "comment": "Text for a link that takes the user to the license file on GitHub.", "isCommentAutoGenerated": true, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iCloud" + "value": "View on GitHub" } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud" + "value": "GitHub에서 보기" } } } }, - "iCloud Restricted": { + "Website": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iCloud Restricted" + "value": "Website" } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud 제한됨" + "value": "웹사이트" } } } }, - "iCloud Sync": { - "comment": "Title of the section in the iCloud Settings view related to iCloud Sync.", - "isCommentAutoGenerated": true, + "When enabled, data on this Mac is merged with iCloud. Turning it off keeps data in both places and stops future sync.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iCloud Sync" + "value": "When enabled, data on this Mac is merged with iCloud. Turning it off keeps data in both places and stops future sync." } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud 동기화" + "value": "켜면 이 Mac의 데이터가 iCloud와 병합됩니다. 끄면 양쪽에 데이터가 유지되고 이후 동기화가 중단됩니다." } } } }, - "iCloud Sync Enabled!": { + "You can change this anytime in Settings.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iCloud Sync Enabled!" + "value": "You can change this anytime in Settings." } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud 동기화가 켜졌어요!" + "value": "설정에서 언제든지 변경할 수 있습니다." } } } }, - "iCloud Temporarily Unavailable.": { + "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, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iCloud Temporarily Unavailable." + "value": "You can restore it later from Trash." } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud를 일시적으로 사용할 수 없어요." + "value": "나중에 휴지통에서 복구할 수 있습니다." } } } }, - "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, + "Your secrets are protected with Touch ID.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iCloud sync isn't available right now. Please try again later." + "value": "Your secrets are protected with Touch ID." } }, "ko": { "stringUnit": { "state": "translated", - "value": "지금은 iCloud 동기화를 사용할 수 없어요. 나중에 다시 시도해 주세요." + "value": "Touch ID로 Secret이 보호되고 있습니다." } } } }, - "iCloud sync isn't available.": { - "comment": "Title of an alert when iCloud sync is unavailable.", + "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, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iCloud sync isn't available." + "value": "Your secrets will sync automatically when a connection is available." } }, "ko": { "stringUnit": { "state": "translated", - "value": "iCloud 동기화를 사용할 수 없어요." + "value": "연결이 가능해지면 Secret이 자동으로 동기화됩니다." } } } }, - "optional": { + "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, "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "optional" + "value": "Your unsaved changes will be lost." } }, "ko": { "stringUnit": { "state": "translated", - "value": "선택" + "value": "저장하지 않은 변경 사항이 사라집니다." } } } }, - "선택됨: %@": { - "comment": "A label that shows the result of the user's selection.", - "isCommentAutoGenerated": true - }, - "아직 선택 안 함": {} + "아직 선택 안 함": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 선택 안 함" + } + } + } + } }, "version": "1.1" } diff --git a/Projects/DVPresentation/Resources/OpenSourceLicenses/ComposableArchitecture-LICENSE.txt b/Projects/DVPresentation/Resources/OpenSourceLicenses/ComposableArchitecture-LICENSE.txt new file mode 100644 index 00000000..9d6cb13b --- /dev/null +++ b/Projects/DVPresentation/Resources/OpenSourceLicenses/ComposableArchitecture-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Point-Free, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Projects/DVPresentation/Resources/OpenSourceLicenses/Lottie-LICENSE.txt b/Projects/DVPresentation/Resources/OpenSourceLicenses/Lottie-LICENSE.txt new file mode 100644 index 00000000..169d50cc --- /dev/null +++ b/Projects/DVPresentation/Resources/OpenSourceLicenses/Lottie-LICENSE.txt @@ -0,0 +1,203 @@ +Copyright 2018 Airbnb, Inc. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Projects/DVPresentation/Sources/AppMenu/AppCommands.swift b/Projects/DVPresentation/Sources/AppMenu/AppCommands.swift new file mode 100644 index 00000000..68143f0a --- /dev/null +++ b/Projects/DVPresentation/Sources/AppMenu/AppCommands.swift @@ -0,0 +1,114 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +import ComposableArchitecture + +// MARK: - AppCommands + +/// macOS App 메뉴에 전역 커맨드를 얹는다. +/// +/// 항목은 ``AppMenuCommand`` 카탈로그에서 나오며, `main` 세션이 활성일 때만 활성화된다 +/// (온보딩·잠금 화면에서는 비활성). +public struct AppCommands: Commands { + private let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some Commands { + // File ▸ New Secret(⌘N, 그리드) / New ▸(타입별 직접 생성) / New Project + CommandGroup(replacing: .newItem) { + button(for: .newSecret) + newSecretTypeMenu + button(for: .newProject) + } + + // File ▸ Lock DeVault (New 그룹 아래 별도 섹션) + CommandGroup(after: .newItem) { + button(for: .lockVault) + } + + // DeVault ▸ Settings… + CommandGroup(replacing: .appSettings) { + button(for: .openSettings) + } + + // Help ▸ 지원·개인정보처리방침·피드백 (기본 도움말 항목 대체) + CommandGroup(replacing: .help) { + ForEach(HelpMenuLink.all, id: \.self) { link in + Link(link.title, destination: link.url) + } + } + + // View ▸ Show/Hide Sidebar (⌃⌘S). NavigationSplitView와 자동 연동. + SidebarCommands() + + // View ▸ Filters (사이드바 필터를 ⌘1–⌘5로 선택) + CommandGroup(after: .sidebar) { + filtersMenu + } + } + +} + +// MARK: - Menu Items + +extension AppCommands { + + private func button(for command: AppMenuCommand) -> some View { + Button(command.title) { + store.send(command.action) + } + .keyboardShortcut(command.keyboardShortcut) + .disabled(isDisabled(command)) + } + + /// main 세션이 없으면(온보딩·잠금) 모두 비활성. 콘텐츠를 바꾸는 커맨드는 **설정 화면에서도** + /// 비활성화한다 — 설정엔 사이드바·리스트가 없어, 켜두면 보이지 않는 상태만 바뀌고 + /// 설정을 닫는 순간 요청하지 않은 화면(생성 폼·필터)으로 튄다. + private func isDisabled(_ command: AppMenuCommand) -> Bool { + switch command { + case .lockVault, .openSettings: + return store.main == nil + case .newSecret, .newProject: + return !isContentScreenActive + } + } + + /// 사이드바·리스트·생성 플로우가 화면에 있는지(browsing/creating). 설정 화면·비활성 세션이면 false. + private var isContentScreenActive: Bool { + guard let screen = store.main?.screen else { return false } + return screen != .settings + } + + /// File ▸ New ▸ — 타입 선택 그리드를 건너뛰고 6개 타입으로 바로 생성한다. + private var newSecretTypeMenu: some View { + Menu(String.module("New")) { + ForEach(CreatableSecretType.allCases, id: \.self) { type in + Button { + store.send(.main(.createSecretRequested(type))) + } label: { + Label { Text(type.displayName) } icon: { type.icon } + } + } + } + .disabled(!isContentScreenActive) + } + + /// View ▸ Filters ▸ — 사이드바 필터를 메뉴에서 선택. 라벨은 사이드바와 동일 소스(`filter.title`). + private var filtersMenu: some View { + Menu(String.module("Filters")) { + ForEach(Array(SidebarFilter.allCases.enumerated()), id: \.element) { index, filter in + Button { + store.send(.main(.sidebar(.didSelect(.filter(filter))))) + } label: { + Label(filter.title, systemImage: filter.icon) + } + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) + } + } + .disabled(!isContentScreenActive) + } +} diff --git a/Projects/DVPresentation/Sources/AppMenu/AppMenuCommand.swift b/Projects/DVPresentation/Sources/AppMenu/AppMenuCommand.swift new file mode 100644 index 00000000..31ed00f5 --- /dev/null +++ b/Projects/DVPresentation/Sources/AppMenu/AppMenuCommand.swift @@ -0,0 +1,83 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +// MARK: - AppMenuCommand + +/// macOS App 메뉴에 노출되는 전역 커맨드의 단일 소스. +/// +/// 표시 문자열(`displayKeys`)과 실제 단축키(`keyboardShortcut`)가 같은 `key`/`modifiers`에서 +/// 파생되므로, App 메뉴와 Shortcuts 설정 화면 사이에 값이 어긋날 수 없다. +enum AppMenuCommand: CaseIterable, Hashable { + case newSecret + case newProject + case lockVault + case openSettings + + /// 메뉴·설정 화면에 노출되는 순서. + static let all: [AppMenuCommand] = AppMenuCommand.allCases + + // MARK: - Title + + var title: String { + switch self { + case .newSecret: .module("New Secret") + case .newProject: .module("New Project") + case .lockVault: .module("Lock DeVault") + case .openSettings: .module("Settings…") + } + } + + // MARK: - Shortcut + + var key: KeyEquivalent { + switch self { + case .newSecret: "n" + case .newProject: "n" + case .lockVault: "l" + case .openSettings: "," + } + } + + var modifiers: EventModifiers { + switch self { + case .newSecret: .command + case .newProject: [.command, .shift] + case .lockVault: [.command, .control] + case .openSettings: .command + } + } + + /// SwiftUI `.keyboardShortcut`에 그대로 넘길 값. + var keyboardShortcut: KeyboardShortcut { + KeyboardShortcut(key, modifiers: modifiers) + } + + /// Shortcuts 설정 화면에 표시할 단축키 문자열. macOS 표기 순서(⌃⌥⇧⌘) + 키. + var displayKeys: String { + var result = "" + if modifiers.contains(.control) { result += "⌃" } + if modifiers.contains(.option) { result += "⌥" } + if modifiers.contains(.shift) { result += "⇧" } + if modifiers.contains(.command) { result += "⌘" } + result += keyDisplay + return result + } + + private var keyDisplay: String { + let character = key.character + return character.isLetter ? character.uppercased() : String(character) + } + + // MARK: - Action + + /// 메뉴 선택 시 store로 보낼 액션. 각 항목은 기존 UI와 동일한 진입점을 재사용한다. + var action: AppFeature.Action { + switch self { + case .newSecret: .main(.sidebar(.didTapAddButton)) + case .newProject: .main(.sidebar(.didTapAddProject)) + case .lockVault: .main(.didTapLock) + case .openSettings: .main(.sidebar(.didTapSettings)) + } + } +} diff --git a/Projects/DVPresentation/Sources/AppMenu/HelpMenuLink.swift b/Projects/DVPresentation/Sources/AppMenu/HelpMenuLink.swift new file mode 100644 index 00000000..d2f6876d --- /dev/null +++ b/Projects/DVPresentation/Sources/AppMenu/HelpMenuLink.swift @@ -0,0 +1,34 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +// MARK: - HelpMenuLink + +/// macOS Help 메뉴에 노출되는 외부 링크의 단일 소스. +/// +/// store 액션이 아니라 외부 URL을 여는 항목이라 ``AppMenuCommand``와 분리한다. +/// (온보딩·잠금 화면에서도 항상 열 수 있어야 하므로 활성 조건도 없다.) +enum HelpMenuLink: CaseIterable, Hashable { + case help + case privacyPolicy + case sendFeedback + + /// 메뉴에 노출되는 순서. + static let all: [HelpMenuLink] = HelpMenuLink.allCases + + var title: String { + switch self { + case .help: .module("DeVault Help") + case .privacyPolicy: .module("Privacy Policy") + case .sendFeedback: .module("Send Feedback") + } + } + + var url: URL { + switch self { + case .help: URL(string: "https://devault-support.notion.site/")! + case .privacyPolicy: URL(string: "https://devault-policy.notion.site/")! + case .sendFeedback: URL(string: "mailto:devault.devteam@gmail.com")! + } + } +} diff --git a/Projects/DVPresentation/Sources/Dependencies/AppLaunchClient.swift b/Projects/DVPresentation/Sources/Dependencies/App/AppLaunchClient.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/AppLaunchClient.swift rename to Projects/DVPresentation/Sources/Dependencies/App/AppLaunchClient.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/AppLifecycleClient.swift b/Projects/DVPresentation/Sources/Dependencies/App/AppLifecycleClient.swift similarity index 93% rename from Projects/DVPresentation/Sources/Dependencies/AppLifecycleClient.swift rename to Projects/DVPresentation/Sources/Dependencies/App/AppLifecycleClient.swift index af849af1..df9864b2 100644 --- a/Projects/DVPresentation/Sources/Dependencies/AppLifecycleClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/App/AppLifecycleClient.swift @@ -16,8 +16,6 @@ public enum AppLifecycleEvent: Equatable, Sendable { case didEnterBackground /// 잠금 화면으로 돌아갔다. - /// - /// 자동 잠금 기능이 아직 없어 현재 발신자가 없다. 기능이 생기면 그쪽에서 보내면 된다. case didLock } diff --git a/Projects/DVPresentation/Sources/Dependencies/WindowCapture/WindowCaptureBlockerClient.swift b/Projects/DVPresentation/Sources/Dependencies/App/WindowCaptureBlockerClient.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/WindowCapture/WindowCaptureBlockerClient.swift rename to Projects/DVPresentation/Sources/Dependencies/App/WindowCaptureBlockerClient.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/LockClient.swift b/Projects/DVPresentation/Sources/Dependencies/Lock/LockClient.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/LockClient.swift rename to Projects/DVPresentation/Sources/Dependencies/Lock/LockClient.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/OnboardingClient.swift b/Projects/DVPresentation/Sources/Dependencies/Onboarding/OnboardingClient.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/OnboardingClient.swift rename to Projects/DVPresentation/Sources/Dependencies/Onboarding/OnboardingClient.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/ProjectClient.swift b/Projects/DVPresentation/Sources/Dependencies/Project/ProjectClient.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/ProjectClient.swift rename to Projects/DVPresentation/Sources/Dependencies/Project/ProjectClient.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/DetectionClient.swift b/Projects/DVPresentation/Sources/Dependencies/Secret/DetectionClient.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/DetectionClient.swift rename to Projects/DVPresentation/Sources/Dependencies/Secret/DetectionClient.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient+Preview.swift b/Projects/DVPresentation/Sources/Dependencies/Secret/SecretClient+Preview.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/SecretClient+Preview.swift rename to Projects/DVPresentation/Sources/Dependencies/Secret/SecretClient+Preview.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/Secret/SecretClient.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/SecretClient.swift rename to Projects/DVPresentation/Sources/Dependencies/Secret/SecretClient.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretManagementClient.swift b/Projects/DVPresentation/Sources/Dependencies/Secret/SecretManagementClient.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/SecretManagementClient.swift rename to Projects/DVPresentation/Sources/Dependencies/Secret/SecretManagementClient.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/Settings/GeneralSettingsClient.swift b/Projects/DVPresentation/Sources/Dependencies/Settings/GeneralSettingsClient.swift index 12d7de65..1faf91ce 100644 --- a/Projects/DVPresentation/Sources/Dependencies/Settings/GeneralSettingsClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/Settings/GeneralSettingsClient.swift @@ -12,6 +12,10 @@ public struct GeneralSettingsClient: Sendable { public var defaultEnvironment: @Sendable () -> String = { "dev" } public var setDefaultEnvironment: @Sendable (String) -> Void + + public var appearance: @Sendable () -> String = { "system" } + public var setAppearance: @Sendable (String) -> Void + public var appearanceStream: @Sendable () -> AsyncStream = { AsyncStream { $0.finish() } } } extension GeneralSettingsClient: TestDependencyKey { @@ -22,7 +26,10 @@ extension GeneralSettingsClient: TestDependencyKey { setLaunchAtLoginEnabled: { _ in .notRegistered }, openLoginItemsSystemSettings: { }, defaultEnvironment: { "dev" }, - setDefaultEnvironment: { _ in } + setDefaultEnvironment: { _ in }, + appearance: { "system" }, + setAppearance: { _ in }, + appearanceStream: { AsyncStream { $0.finish() } } ) } diff --git a/Projects/DVPresentation/Sources/Dependencies/Settings/ICloudSettingsClient.swift b/Projects/DVPresentation/Sources/Dependencies/Settings/ICloudSettingsClient.swift index 214f8ea7..c6cd3030 100644 --- a/Projects/DVPresentation/Sources/Dependencies/Settings/ICloudSettingsClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/Settings/ICloudSettingsClient.swift @@ -11,7 +11,6 @@ public struct ICloudSettingsClient: Sendable { public var setEnabled: @Sendable (Bool) async throws -> Void public var openSystemSettings: @Sendable () -> Void public var lastUpdateDetectedAt: @Sendable () -> Date? - public var setLastUpdateDetectedAt: @Sendable (Date) -> Void /// CloudKit 원격 변경이 감지될 때마다 값을 방출한다. public var remoteChangeStream: @Sendable () -> AsyncStream = { AsyncStream { $0.finish() } } public var accountStatus: @Sendable () async -> ICloudAccountStatus = { .couldNotDetermine } @@ -25,7 +24,6 @@ extension ICloudSettingsClient: TestDependencyKey { setEnabled: { _ in }, openSystemSettings: { }, lastUpdateDetectedAt: { nil }, - setLastUpdateDetectedAt: { _ in }, remoteChangeStream: { AsyncStream { $0.finish() } }, accountStatus: { .available } ) diff --git a/Projects/DVPresentation/Sources/Dependencies/SidebarClient.swift b/Projects/DVPresentation/Sources/Dependencies/Sidebar/SidebarClient.swift similarity index 100% rename from Projects/DVPresentation/Sources/Dependencies/SidebarClient.swift rename to Projects/DVPresentation/Sources/Dependencies/Sidebar/SidebarClient.swift diff --git a/Projects/DVPresentation/Sources/Features/AppFeature.swift b/Projects/DVPresentation/Sources/Features/AppFeature.swift index e66cd098..a6fd5023 100644 --- a/Projects/DVPresentation/Sources/Features/AppFeature.swift +++ b/Projects/DVPresentation/Sources/Features/AppFeature.swift @@ -18,6 +18,7 @@ public struct AppFeature { var locked: LockFeature.State? var main: MainFeature.State? public var isWindowCaptureBlockingEnabled = true + public var appearance: AppAppearance = .system /// 지금 어느 화면인지. 전환 애니메이션이 이 값 하나를 본다. /// 셋을 다 비웠다가 하나를 세우는 구간이 있어(`task`) 옵셔널이다. @@ -49,6 +50,7 @@ public struct AppFeature { // MARK: - Internal case windowCaptureBlockingChanged(Bool) + case appearanceChanged(AppAppearance) case iCloudRemoteChangeDetected case iCloudRemoteChangeHandled @@ -70,6 +72,7 @@ public struct AppFeature { @Dependency(\.appLaunchClient) var appLaunchClient @Dependency(\.appSecurityClient) var appSecurityClient @Dependency(\.windowCaptureBlockerClient) var windowCaptureBlockerClient + @Dependency(\.generalSettingsClient) var generalSettingsClient @Dependency(\.continuousClock) var clock @Dependency(\.date.now) var now @@ -82,6 +85,7 @@ public struct AppFeature { private enum CancelID { case inactivityWatch case windowCaptureSettingsWatch + case appearanceWatch case iCloudRemoteChangeWatch case iCloudRemoteChangeHandling } @@ -116,6 +120,7 @@ public struct AppFeature { : .none, state.main != nil ? inactivityWatchEffect() : .none, windowCaptureSettingsWatchEffect(), + appearanceWatchEffect(), iCloudRemoteChangeWatchEffect() ) @@ -123,6 +128,10 @@ public struct AppFeature { state.isWindowCaptureBlockingEnabled = isEnabled return .none + case let .appearanceChanged(appearance): + state.appearance = appearance + return .none + case .iCloudRemoteChangeDetected: let detectedAt = now return .run { send in @@ -214,6 +223,15 @@ private extension AppFeature { .cancellable(id: CancelID.windowCaptureSettingsWatch, cancelInFlight: true) } + func appearanceWatchEffect() -> Effect { + .run { send in + for await rawValue in generalSettingsClient.appearanceStream() { + await send(.appearanceChanged(AppAppearance(rawValue: rawValue) ?? .system)) + } + } + .cancellable(id: CancelID.appearanceWatch, cancelInFlight: true) + } + func iCloudRemoteChangeWatchEffect() -> Effect { .run { send in for await _ in appLaunchClient.iCloudRemoteChangeStream() { diff --git a/Projects/DVPresentation/Sources/Features/AppView.swift b/Projects/DVPresentation/Sources/Features/AppView.swift index 173704c7..5069f1e2 100644 --- a/Projects/DVPresentation/Sources/Features/AppView.swift +++ b/Projects/DVPresentation/Sources/Features/AppView.swift @@ -26,6 +26,8 @@ public struct AppView: View { .animation(MotionMetrics.transition, value: store.screen) // 진행 오버레이를 그리는 유일한 지점 (`.omc/GUIDELINES.md`). .windowBusyOverlay() + // 잠금 전환 시 메인이 사라지며 남는 고아 시트·알럿을 창에서 직접 닫는다. + .background(LockSheetDismisser(isLocked: store.locked != nil)) .task { store.send(.task) } } } diff --git a/Projects/DVPresentation/Sources/Features/CreateProject/CreateProjectView.swift b/Projects/DVPresentation/Sources/Features/CreateProject/CreateProjectView.swift index 0a057102..ef534315 100644 --- a/Projects/DVPresentation/Sources/Features/CreateProject/CreateProjectView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateProject/CreateProjectView.swift @@ -29,7 +29,7 @@ extension CreateProjectView { VStack(alignment: .leading, spacing: 16) { Text(.module("Create Project")) .dvFont(.bodyLG) - .foregroundStyle(Color.dv(.black)) + .foregroundStyle(Color.dv(.gray900)) VStack(alignment: .leading, spacing: 8) { Text(.module("Project Name")) diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/ExpireDateFieldView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/ExpireDateFieldView.swift index 679ec4cb..0aed8150 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/ExpireDateFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/ExpireDateFieldView.swift @@ -84,6 +84,7 @@ private extension ExpireDateFieldView { } .buttonStyle(.plain) .disabled(!isSet) + .accessibilityLabel(String.module("Clear")) } /// `DatePicker`가 non-optional `Date`를 요구하므로 옵셔널 shim. diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/LabeledTextFieldView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/LabeledTextFieldView.swift index 24ae2da3..efd057c8 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/LabeledTextFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/LabeledTextFieldView.swift @@ -60,7 +60,14 @@ struct LabeledTextFieldView: View { trailingHint: trailingHint, size: size ) { - DVTextField(placeholder, text: $text, size: size, isSecure: isSecure) + DVTextField( + placeholder, + text: $text, + size: size, + isSecure: isSecure, + revealAccessibilityLabel: String.module("Show"), + hideAccessibilityLabel: String.module("Hide") + ) } } } diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/SSLRequiredFieldView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/SSLRequiredFieldView.swift index 472f06a2..d7a3ddf4 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/SSLRequiredFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/SSLRequiredFieldView.swift @@ -14,7 +14,10 @@ struct SSLRequiredFieldView: View { Text(.module("SSL Required")) .dvFont(.bodyMD) .foregroundStyle(Color.dv(.gray700)) + // 라벨은 체크박스가 대신 읽으므로 중복 방지로 숨긴다. + .accessibilityHidden(true) DVCheckBox(isChecked: isChecked) { isChecked.toggle() } + .accessibilityLabel(String.module("SSL Required")) } } } diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 013e5517..c53c81af 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -27,6 +27,9 @@ public struct MainFeature { /// 생성 폼의 취소 확인을 기다리는 동안 보관하는 이동 목적지. var pendingSelection: SidebarSelection? + /// New▸로 요청한 타입을, 작성 중이던 폼을 취소 확인한 뒤 열기 위해 보관한다. + var pendingCreateType: CreatableSecretType? + /// 지금 무엇을 그릴지. **화면 분기는 이 값 하나만 본다.** /// 뷰에서 optional을 직접 조합하면 같은 판정이 렌더 지점마다 흩어진다. var screen: Screen { @@ -54,6 +57,8 @@ public struct MainFeature { case binding(BindingAction) case task case didTapLock + /// App 메뉴 서브메뉴에서 타입 선택 그리드를 건너뛰고 해당 타입으로 바로 생성. + case createSecretRequested(CreatableSecretType) // MARK: - Internal @@ -187,6 +192,17 @@ public struct MainFeature { case .secretDetail: return .none + case .createSecretRequested(let secretType): + // 설정 화면에선 사이드바·리스트가 없어, 진행하면 보이지 않는 상태만 바뀐다(설정 닫을 때 튐). + guard state.settings == nil else { return .none } + // 작성 중인 폼이 있으면 덮어쓰지 않는다 — 다른 진입점(⌘N·사이드바)처럼 취소 확인을 거치고, + // 확인되면(cancelled) 이 타입으로 새 폼을 연다. + guard state.createSecret == nil else { + state.pendingCreateType = secretType + return .send(.createSecret(.didTapCancel)) + } + return startCreating(secretType: secretType, &state) + case .selectSecretType(.delegate(.typeSelected(let secretType))): state.createSecret = CreateSecretFeature.State(secretType: secretType) return .none @@ -211,6 +227,11 @@ public struct MainFeature { // 사이드바가 시작한 취소면 목록으로, 폼의 Cancel이면 타입 그리드로 — 목적지가 다르다. case .createSecret(.delegate(.cancelled)): + // New▸로 다른 타입을 요청해 폼을 접은 경우: 목록/그리드가 아니라 그 타입 폼을 새로 연다. + if let secretType = state.pendingCreateType { + state.pendingCreateType = nil + return startCreating(secretType: secretType, &state) + } guard let pending = state.pendingSelection else { state.createSecret = nil state.selectSecretType = .init() @@ -224,6 +245,7 @@ public struct MainFeature { // 사이드바는 아직 움직이지 않았으므로 되돌릴 것이 없다. case .createSecret(.alert(.dismiss)): state.pendingSelection = nil + state.pendingCreateType = nil return .none case .createSecret: @@ -328,6 +350,16 @@ extension MainFeature { state.secretList.retarget(to: target.collection, projectName: target.projectName) } + /// New▸ 진입: 타입 그리드를 건너뛰고 해당 타입 폼으로 바로 들어간다. 조회 중이던 상세를 놓아 + /// 스테일 상세 재등장을 막고, 사이드바가 아무 것도 선택되지 않은 상태로 표시되게 한다. + private func startCreating(secretType: CreatableSecretType, _ state: inout State) -> Effect { + state.selectSecretType = nil + state.createSecret = CreateSecretFeature.State(secretType: secretType) + state.secretDetail = nil + state.secretList.selectedSecretID = nil + return .send(.sidebar(.setCreatingSecret(true))) + } + /// 생성 플로우로 들어간다. 조회 중이던 시크릿을 함께 놓는다 — 상세 State가 살아남으면 /// 돌아올 때 되살아나고 `.task(id:)`가 Touch ID까지 다시 요구한다. private func enterCreating(_ state: inout State) { @@ -372,6 +404,7 @@ extension MainFeature { state.secretDetail = nil // 여기서 폼을 닫으면 `.alert(.dismiss)`가 다시 올 일이 없어 목적지가 영영 남는다. state.pendingSelection = nil + state.pendingCreateType = nil state.sidebar.mode = .browsing(.filter(.all)) state.secretList = .init(collection: .all) } diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailReadOnlyFieldView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailReadOnlyFieldView.swift index 1ff76437..076b92a4 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailReadOnlyFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailReadOnlyFieldView.swift @@ -139,6 +139,7 @@ extension DetailReadOnlyFieldView { } label: { Image(systemName: "doc.on.doc") } + .accessibilityLabel(String.module("Copy")) } if showsRevealToggle { Button { @@ -146,6 +147,7 @@ extension DetailReadOnlyFieldView { } label: { Image(systemName: actions.isRevealed(field) ? "eye.slash" : "eye") } + .accessibilityLabel(actions.isRevealed(field) ? String.module("Hide") : String.module("Reveal")) } } .font(.system(size: 11)) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index f29526bf..2cb81a18 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -64,9 +64,13 @@ extension SecretListView { searchText: searchTextBinding, searchPromptText: .module("Search"), isSearchFocused: $isSearchFocused, - sortMenuContent: showsSort ? { AnyView(sortMenuContent) } : nil + sortMenuContent: showsSort ? { AnyView(sortMenuContent) } : nil, + sortAccessibilityLabel: .module("Sort") ) .padding(.horizontal, 12) + .background { + Color.dv(.gray100).ignoresSafeArea(edges: .top) + } } } @@ -83,7 +87,7 @@ extension SecretListView { } .listStyle(.sidebar) .scrollContentBackground(.hidden) - .tint(Color(nsColor: .controlAccentColor).opacity(0.6)) + .tint(Color.dv(.vaultGreen)) .animation(MotionMetrics.layout, value: secrets) // 필터가 바뀌면 행이 통째로 갈린다. 같은 목록으로 두면 무관한 행을 하나씩 지우고 넣는 // 것으로 그려 어수선해지므로 새 내용으로 본다. @@ -95,10 +99,9 @@ extension SecretListView { // `contentShape`은 행이 없는 빈 영역도 눌리게 하려고 남긴다. .contentShape(Rectangle()) .simultaneousGesture(TapGesture().onEnded { isSearchFocused = false }) - // `content`가 헤더를 ZStack으로 위에 얹으므로, 리스트 쪽만 헤더 높이만큼 안전 영역을 예약해 - // 헤더에 가리지 않게 한다. + // `content`가 헤더를 ZStack으로 위에 얹으므로, 리스트 쪽만 헤더 높이만큼 안전 영역을 예약해 헤더에 가리지 않게 한다. .safeAreaInset(edge: .top, spacing: 0) { - Color.clear.frame(height: Self.headerReservedHeight) + Color.dv(.gray100).frame(height: Self.headerReservedHeight) } } @@ -112,13 +115,13 @@ extension SecretListView { service: secret.service, typeIcon: secret.secretType.icon, trailingIcon: badgeStatus?.emphasis, - trailingIconTooltip: badgeStatus?.tooltipText, - isSelected: secret.id == store.selectedSecretID + trailingIconTooltip: badgeStatus?.tooltipText ) .tag(secret.id) .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) .listRowSeparator(.hidden) + // 이름·날짜·만료 배지를 하나의 접근성 요소로 묶어 VoiceOver가 한 번에 읽게 한다. + .accessibilityElement(children: .combine) .contextMenu { contextMenuItems(for: secret) } @@ -213,6 +216,7 @@ extension SecretListView { Image(systemName: "exclamationmark.triangle") .dvFont(.bodyLG) .foregroundStyle(Color.dv(.gray400)) + .accessibilityHidden(true) Text(.module("Failed to load the list.")) .dvFont(.bodyMD) .foregroundStyle(Color.dv(.gray500)) @@ -240,11 +244,11 @@ extension SecretListView { private var titleText: String { switch store.collection { - case .all: return "All" - case .liked: return "Star" - case .notice: return "Notice" - case .expired: return "Expired" - case .deleted: return "Deleted" + case .all: return .module("All") + case .liked: return .module("Star") + case .notice: return .module("Notice") + case .expired: return .module("Expired") + case .deleted: return .module("Deleted") case .project: return store.projectName ?? .module("Project") } } diff --git a/Projects/DVPresentation/Sources/Features/SelectSecretType/SelectSecretTypeView.swift b/Projects/DVPresentation/Sources/Features/SelectSecretType/SelectSecretTypeView.swift index a414fe80..5fed0c90 100644 --- a/Projects/DVPresentation/Sources/Features/SelectSecretType/SelectSecretTypeView.swift +++ b/Projects/DVPresentation/Sources/Features/SelectSecretType/SelectSecretTypeView.swift @@ -48,6 +48,7 @@ extension SelectSecretTypeView { ) } .buttonStyle(.plain) + .accessibilityLabel(String(localized: type.displayName)) } } diff --git a/Projects/DVPresentation/Sources/Features/Settings/About/AboutSettingsFeature.swift b/Projects/DVPresentation/Sources/Features/Settings/About/AboutSettingsFeature.swift index 04979ed1..6382dff1 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/About/AboutSettingsFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/About/AboutSettingsFeature.swift @@ -12,17 +12,20 @@ public struct AboutSettingsFeature { @ObservableState public struct State: Equatable { var version = "-" + var isShowingLicenses = false public init() {} } // MARK: - Action - public enum Action: Equatable { + public enum Action: BindableAction, Equatable { // MARK: - View + case binding(BindingAction) case task + case didTapOpenSourceLicenses } // MARK: - Dependencies @@ -36,11 +39,19 @@ public struct AboutSettingsFeature { // MARK: - Body public var body: some ReducerOf { + BindingReducer() Reduce { state, action in switch action { + case .binding: + return .none + case .task: state.version = aboutSettingsClient.appVersion() return .none + + case .didTapOpenSourceLicenses: + state.isShowingLicenses = true + return .none } } } diff --git a/Projects/DVPresentation/Sources/Features/Settings/About/AboutSettingsView.swift b/Projects/DVPresentation/Sources/Features/Settings/About/AboutSettingsView.swift index 286baab7..d6902edd 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/About/AboutSettingsView.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/About/AboutSettingsView.swift @@ -8,7 +8,7 @@ import DVDesign struct AboutSettingsView: View { - let store: StoreOf + @Bindable var store: StoreOf private static let licenseURL = URL( string: "https://github.com/DevaultProject/Devault-macOS/blob/develop/LICENSE" ) @@ -16,6 +16,9 @@ struct AboutSettingsView: View { var body: some View { content .task { await store.send(.task).finish() } + .sheet(isPresented: $store.isShowingLicenses) { + OpenSourceLicensesView() + } } } @@ -44,6 +47,30 @@ extension AboutSettingsView { destination: licenseURL ) } + // 서드파티 라이선스는 전문을 앱에 번들해 sheet로 보여준다. + SettingsButtonRow( + title: String.module("Open Source Licenses"), + buttonTitle: String.module("View"), + action: { store.send(.didTapOpenSourceLicenses) } + ) + } + + SettingsSection(title: String.module("Support")) { + SettingsLinkRow( + title: String.module("Help"), + linkTitle: String.module("Support Center"), + destination: HelpMenuLink.help.url + ) + SettingsLinkRow( + title: String.module("Privacy Policy"), + linkTitle: String.module("View"), + destination: HelpMenuLink.privacyPolicy.url + ) + SettingsLinkRow( + title: String.module("Contact"), + linkTitle: String.module("Email"), + destination: HelpMenuLink.sendFeedback.url + ) } } } diff --git a/Projects/DVPresentation/Sources/Features/Settings/About/Model/OpenSourceLicense.swift b/Projects/DVPresentation/Sources/Features/Settings/About/Model/OpenSourceLicense.swift new file mode 100644 index 00000000..94de28ce --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Settings/About/Model/OpenSourceLicense.swift @@ -0,0 +1,46 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +// MARK: - OpenSourceLicense + +/// About에 노출하는 서드파티 오픈소스 라이선스. 전문은 모듈 번들의 텍스트 리소스에서 로드한다. +enum OpenSourceLicense: CaseIterable, Hashable { + case composableArchitecture + case lottie + + /// 화면에 노출되는 순서. + static let all: [OpenSourceLicense] = allCases + + var name: String { + switch self { + case .composableArchitecture: "ComposableArchitecture" + case .lottie: "Lottie" + } + } + + var licenseName: String { + switch self { + case .composableArchitecture: "MIT" + case .lottie: "Apache 2.0" + } + } + + /// 라이선스 전문. 번들 리소스에서 읽어오며, 실패 시 빈 문자열. + var text: String { + guard + let url = Bundle.module.url(forResource: resourceName, withExtension: "txt"), + let content = try? String(contentsOf: url, encoding: .utf8) + else { + return "" + } + return content + } + + private var resourceName: String { + switch self { + case .composableArchitecture: "ComposableArchitecture-LICENSE" + case .lottie: "Lottie-LICENSE" + } + } +} diff --git a/Projects/DVPresentation/Sources/Features/Settings/About/OpenSourceLicensesView.swift b/Projects/DVPresentation/Sources/Features/Settings/About/OpenSourceLicensesView.swift new file mode 100644 index 00000000..151f99a9 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Settings/About/OpenSourceLicensesView.swift @@ -0,0 +1,49 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +import DVDesign + +/// 서드파티 오픈소스 라이선스 전문을 스크롤로 보여주는 sheet 화면. +struct OpenSourceLicensesView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + ForEach(OpenSourceLicense.all, id: \.self) { license in + VStack(alignment: .leading, spacing: 8) { + Text(license.name) + .dvFont(.bodyLG) + .foregroundStyle(Color.dv(.gray900)) + Text(license.licenseName) + .dvFont(.captionMDRegular) + .foregroundStyle(Color.dv(.gray600)) + Text(license.text) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(Color.dv(.gray900)) + .textSelection(.enabled) + .padding(.top, 4) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(24) + } + .navigationTitle(String.module("Open Source Licenses")) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(String.module("Done")) { dismiss() } + } + } + } + .frame(minWidth: 560, minHeight: 450) + } +} + +// MARK: - Preview + +#Preview("Open Source Licenses") { + OpenSourceLicensesView() +} diff --git a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsButtonRow.swift b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsButtonRow.swift index 44eb8549..3db857b6 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsButtonRow.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsButtonRow.swift @@ -72,6 +72,6 @@ struct SettingsButtonRow: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .frame(width: 656) + .frame(width: WindowLayoutMetrics.settingsDetailWidth) .background(Color.dv(.gray100)) } diff --git a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsLinkRow.swift b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsLinkRow.swift index 95c98bc1..fdd13043 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsLinkRow.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsLinkRow.swift @@ -61,6 +61,6 @@ struct SettingsLinkRow: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .frame(width: 656) + .frame(width: WindowLayoutMetrics.settingsDetailWidth) .background(Color.dv(.gray100)) } diff --git a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsPickerRow.swift b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsPickerRow.swift index 9aa7275b..5ec52c7b 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsPickerRow.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsPickerRow.swift @@ -69,7 +69,7 @@ private struct SettingsPickerRowPreview: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .frame(width: 656) + .frame(width: WindowLayoutMetrics.settingsDetailWidth) .background(Color.dv(.gray100)) } } diff --git a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsSection.swift b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsSection.swift index 7a1a49ba..4fe3d41b 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsSection.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsSection.swift @@ -61,7 +61,7 @@ struct SettingsDetailPreview: View { var body: some View { content() - .frame(width: 656, height: 560, alignment: .topLeading) + .frame(width: WindowLayoutMetrics.settingsDetailWidth, height: 560, alignment: .topLeading) .dvScreenBackground() } } @@ -85,6 +85,6 @@ struct SettingsDetailPreview: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .frame(width: 656) + .frame(width: WindowLayoutMetrics.settingsDetailWidth) .background(Color.dv(.gray100)) } diff --git a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsToggleRow.swift b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsToggleRow.swift index 60bcdf7a..50109c69 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsToggleRow.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsToggleRow.swift @@ -51,6 +51,6 @@ struct SettingsToggleRow: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .frame(width: 656) + .frame(width: WindowLayoutMetrics.settingsDetailWidth) .background(Color.dv(.gray100)) } diff --git a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsValueRow.swift b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsValueRow.swift index 4830a2cb..de676c45 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsValueRow.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/Components/SettingsValueRow.swift @@ -85,6 +85,6 @@ struct SettingsValueRow: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .frame(width: 656) + .frame(width: WindowLayoutMetrics.settingsDetailWidth) .background(Color.dv(.gray100)) } diff --git a/Projects/DVPresentation/Sources/Features/Settings/General/GeneralSettingsFeature.swift b/Projects/DVPresentation/Sources/Features/Settings/General/GeneralSettingsFeature.swift index 54d8dcef..a8c9145f 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/General/GeneralSettingsFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/General/GeneralSettingsFeature.swift @@ -15,6 +15,7 @@ public struct GeneralSettingsFeature { var isLaunchAtLoginEnabled = false var launchAtLoginStatus: LaunchAtLoginStatus = .notRegistered var defaultEnvironment: SecretEnvironment = .dev + var appearance: AppAppearance = .system public init() {} } @@ -52,6 +53,9 @@ public struct GeneralSettingsFeature { state.defaultEnvironment = SecretEnvironment( rawValue: generalSettingsClient.defaultEnvironment() ) ?? .dev + state.appearance = AppAppearance( + rawValue: generalSettingsClient.appearance() + ) ?? .system return .none case .binding(\.isLaunchAtLoginEnabled): @@ -72,6 +76,10 @@ public struct GeneralSettingsFeature { let environment = state.defaultEnvironment return .run { _ in generalSettingsClient.setDefaultEnvironment(environment.rawValue) } + case .binding(\.appearance): + let appearance = state.appearance + return .run { _ in generalSettingsClient.setAppearance(appearance.rawValue) } + case .binding: return .none diff --git a/Projects/DVPresentation/Sources/Features/Settings/General/GeneralSettingsView.swift b/Projects/DVPresentation/Sources/Features/Settings/General/GeneralSettingsView.swift index 3dd6c07a..504134e3 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/General/GeneralSettingsView.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/General/GeneralSettingsView.swift @@ -44,6 +44,19 @@ extension GeneralSettingsView { } } + SettingsSection(title: String.module("Appearance")) { + SettingsPickerRow( + title: String.module("Theme"), + description: String.module("Choose light or dark, or match your system setting."), + selection: $store.appearance + ) { + ForEach(AppAppearance.allCases, id: \.self) { appearance in + Text(String(localized: appearance.displayName)) + .tag(appearance) + } + } + } + SettingsSection(title: String.module("Defaults")) { SettingsPickerRow( title: String.module("Default environment"), diff --git a/Projects/DVPresentation/Sources/Features/Settings/General/Model/AppAppearance.swift b/Projects/DVPresentation/Sources/Features/Settings/General/Model/AppAppearance.swift new file mode 100644 index 00000000..b6b343bf --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Settings/General/Model/AppAppearance.swift @@ -0,0 +1,29 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// `SettingsRepository.appearance`(String)에 `rawValue`로 저장되는 Presentation VO. +/// 앱 전체에 적용할 색 구성(시스템/라이트/다크)을 나타낸다. +public enum AppAppearance: String, CaseIterable, Hashable, Sendable { + case system + case light + case dark + + /// Appearance picker의 옵션 라벨. DVPresentation 모듈의 String Catalog 룩업 대상. + var displayName: LocalizedStringResource { + switch self { + case .system: return .module("System") + case .light: return .module("Light") + case .dark: return .module("Dark") + } + } + + /// 루트 뷰에 적용할 색 구성. `.system`이면 nil을 반환해 macOS 시스템 설정을 따른다. + public var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} diff --git a/Projects/DVPresentation/Sources/Features/Settings/ICloud/ICloudSettingsFeature.swift b/Projects/DVPresentation/Sources/Features/Settings/ICloud/ICloudSettingsFeature.swift index 3c5837a2..0407ee45 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/ICloud/ICloudSettingsFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/ICloud/ICloudSettingsFeature.swift @@ -120,8 +120,10 @@ public struct ICloudSettingsFeature { return requestRefreshStatusEffect() case .remoteChangeDetected: + // 표시만 즉시 갱신한다. 영속화는 상시 동작하는 AppFeature의 원격 변경 핸들러가 단독으로 맡아, + // 같은 값을 두 곳에서 쓰던 중복을 없앤다. state.lastUpdateDetectedAt = now - return .run { [now] _ in iCloudSettingsClient.setLastUpdateDetectedAt(now) } + return .none case let .syncSettingResponse(enabled, succeeded): state.isTogglingSync = false diff --git a/Projects/DVPresentation/Sources/Features/Settings/ICloud/ICloudSettingsView.swift b/Projects/DVPresentation/Sources/Features/Settings/ICloud/ICloudSettingsView.swift index a0bf9b91..e772e2e4 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/ICloud/ICloudSettingsView.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/ICloud/ICloudSettingsView.swift @@ -31,7 +31,9 @@ extension ICloudSettingsView { ), isOn: $store.isSyncEnabled ) - .disabled(store.isTogglingSync) + // `.disabled`는 tint 스위치를 손잡이 없는 단색 캡슐로 그린다(렌더 버그). 상호작용만 막고 흐림으로 표시. + .allowsHitTesting(!store.isTogglingSync) + .opacity(store.isTogglingSync ? 0.6 : 1) } diff --git a/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift b/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift index 52119dba..005bc7e9 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift @@ -84,6 +84,7 @@ extension SettingsView { Image(systemName: "arrow.left") .frame(width: 16, height: 16) .fontWeight(.medium) + .accessibilityHidden(true) Text(.module("Back to App")) .dvFont(.bodyLG) } @@ -104,7 +105,7 @@ extension SettingsView { private var detailColumn: some View { detailContent - .frame(maxWidth: 656, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: WindowLayoutMetrics.settingsDetailWidth, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .dvScreenBackground() } diff --git a/Projects/DVPresentation/Sources/Features/Settings/Shortcuts/ShortcutsSettingsView.swift b/Projects/DVPresentation/Sources/Features/Settings/Shortcuts/ShortcutsSettingsView.swift index 7d12885d..73431ad8 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/Shortcuts/ShortcutsSettingsView.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/Shortcuts/ShortcutsSettingsView.swift @@ -4,20 +4,6 @@ import SwiftUI import DVDesign -// MARK: - AppShortcut - -private struct AppShortcut: Identifiable { - var id: String { keys } - let keys: String - let title: String - - // TODO: - 현재는 임시 값, 추후 Feature/Client 연결 예정 - static let all: [AppShortcut] = [ - AppShortcut(keys: "⌘N", title: String.module("New Secret")), - AppShortcut(keys: "⌘,", title: String.module("Open Settings")), - ] -} - struct ShortcutsSettingsView: View { var body: some View { content @@ -31,10 +17,11 @@ extension ShortcutsSettingsView { private var content: some View { SettingsDetailContainer(title: String.module("Shortcuts")) { SettingsSection(title: String.module("App Shortcuts")) { - ForEach(AppShortcut.all) { shortcut in + // App 메뉴와 동일한 단일 소스(`AppMenuCommand`)에서 제목·단축키를 그대로 노출한다. + ForEach(AppMenuCommand.all, id: \.self) { command in SettingsValueRow( - title: shortcut.title, - value: shortcut.keys, + title: command.title, + value: command.displayKeys, valueStyle: .emphasized ) } diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index 6e1ef57d..4dfe9611 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") } } @@ -286,7 +286,10 @@ public struct SidebarFeature { guard let id = state.renamingProjectID else { return .none } let name = state.renameText.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { + // 빈 이름은 보존할 입력이 없으므로 편집을 닫아 원래 이름으로 되돌린다(nameTaken과 달리). state.alert = makeRenameEmptyNameAlert() + state.renamingProjectID = nil + state.renameText = "" return .none } return .run { [id, name] send in // 캡처 리스트 명시 diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 73662cc5..dbada005 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -101,7 +101,7 @@ extension SidebarView { private var projectSection: some View { VStack(spacing: 12) { projectSectionHeader - .padding(.horizontal, 8) + .padding(.horizontal, 4) collapsibleProjectBody } // `value`를 좁히지 않으면 이름 변경 입력 한 글자마다 섹션 전체가 다시 애니메이션된다. @@ -109,7 +109,7 @@ extension SidebarView { .animation(MotionMetrics.subtle, value: store.isRefreshingProjects) } - /// **`.clipped()`가 헤더를 포함하면 안 된다.** 클립 경계가 헤더보다 위에 생겨, + /// **접힘 마스크가 헤더를 포함하면 안 된다.** 경계가 헤더보다 위에 생겨, /// 접히는 목록이 "Project" 라벨 위를 지나간 뒤에야 잘린다. private var collapsibleProjectBody: some View { VStack(spacing: 0) { @@ -121,7 +121,10 @@ extension SidebarView { Spacer(minLength: 0) } } - .clipped() + // 접힘은 세로만 자르면 된다. 리스트가 `.padding(.horizontal, -12)`로 넘치므로 마스크를 좌우 12pt 넓혀 하이라이트가 안 깎이게 한다. + .mask { + Rectangle().padding(.horizontal, -12) + } .animation(MotionMetrics.layout, value: store.isProjectSectionExpanded) } @@ -157,7 +160,7 @@ extension SidebarView { } private var projectSectionHeader: some View { - HStack(spacing: 11) { + HStack(spacing: 4) { Text(.module("Project")) .dvFont(.captionMDSemibold) .foregroundStyle(Color.dv(.vaultGreen)) @@ -213,6 +216,7 @@ extension SidebarView { } } .listStyle(.sidebar) + .tint(Color.dv(.vaultGreen)) .animation(MotionMetrics.layout, value: store.projects) .scrollContentBackground(.hidden) .padding(.horizontal, -12) @@ -242,8 +246,7 @@ extension SidebarView { } else { DVProjectContainer( name: project.name, - count: store.counts?.count(forProject: project.id), - isSelected: store.highlighted == .project(id: project.id) + count: store.counts?.count(forProject: project.id) ) } } @@ -304,7 +307,6 @@ private extension SidebarFilter { case .notice: Color.dv(.warning) case .starred: Color.dv(.vaultGreen) case .expired: Color.dv(.danger) - case .deleted: Color.dv(.gray700) default: Color.dv(.gray800) } } diff --git a/Projects/DVPresentation/Sources/Support/LockSheetDismisser.swift b/Projects/DVPresentation/Sources/Support/LockSheetDismisser.swift new file mode 100644 index 00000000..8d0458c0 --- /dev/null +++ b/Projects/DVPresentation/Sources/Support/LockSheetDismisser.swift @@ -0,0 +1,35 @@ +// Copyright © 2026 Devault. All rights reserved + +import AppKit +import SwiftUI + +/// 잠금으로 전환될 때 창에 붙어 있는 시트·알럿을 강제로 닫는다. +/// +/// SwiftUI에서 `.sheet`/`.alert`를 띄운 뷰(메인)가 잠금 화면으로 교체돼 사라지면, AppKit은 그 +/// 시트 창을 dismiss하지 못하고 잠금 위에 **고아**로 남긴다(presenter가 사라지면 바인딩이 nil로 +/// 관찰되지 못한다). 여기서 창에 직접 `endSheet`를 보내 닫는다. +/// +/// 창 크롬(NavigationSplitView 사이드바 토글 등)은 메인이 사라지며 함께 없어지므로 대상이 아니다. +struct LockSheetDismisser: NSViewRepresentable { + let isLocked: Bool + + func makeNSView(context: Context) -> NSView { NSView() } + + func updateNSView(_ nsView: NSView, context: Context) { + let becameLocked = isLocked && !context.coordinator.wasLocked + context.coordinator.wasLocked = isLocked + guard becameLocked else { return } + + // 잠금 상태 반영으로 메인(=presenter)이 사라진 뒤의 창 상태를 봐야 하므로 다음 런루프로 미룬다. + DispatchQueue.main.async { + guard let sheet = nsView.window?.attachedSheet else { return } + nsView.window?.endSheet(sheet) + } + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator { + var wasLocked = false + } +} diff --git a/Projects/DVPresentation/Sources/Support/WindowLayoutMetrics.swift b/Projects/DVPresentation/Sources/Support/WindowLayoutMetrics.swift index d4bbe6c6..b20c62c0 100644 --- a/Projects/DVPresentation/Sources/Support/WindowLayoutMetrics.swift +++ b/Projects/DVPresentation/Sources/Support/WindowLayoutMetrics.swift @@ -46,6 +46,11 @@ public enum WindowLayoutMetrics { public static let windowMinWidth = max(browsingMinWidth, creatingMinWidth) + slack public static let windowMinHeight: CGFloat = 600 + // MARK: - 설정 + + /// 설정 상세 콘텐츠 폭. 최소 창에서 상세 영역을 꽉 채우고, 그 이상에선 이 폭으로 캡한 뒤 가운데 정렬한다. + public static let settingsDetailWidth = windowMinWidth - sidebarWidth + /// 하한에 붙여 열면 3컬럼이 전부 최소 폭이라 답답하다. public static let windowDefaultWidth: CGFloat = 1120 public static let windowDefaultHeight: CGFloat = 700 diff --git a/Projects/DVPresentation/Tests/AppFeatureTests.swift b/Projects/DVPresentation/Tests/AppFeatureTests.swift index 66721f21..018c22a0 100644 --- a/Projects/DVPresentation/Tests/AppFeatureTests.swift +++ b/Projects/DVPresentation/Tests/AppFeatureTests.swift @@ -45,6 +45,7 @@ struct AppFeatureTests { $0.appLaunchClient.iCloudRemoteChangeStream = { AsyncStream { $0.finish() } } $0.appSecurityClient.isRequireAuthOnLaunchEnabled = { true } $0.windowCaptureBlockerClient.enabledStream = { AsyncStream { $0.finish() } } + $0.generalSettingsClient.appearanceStream = { AsyncStream { $0.finish() } } } await store.send(.task) { @@ -64,6 +65,7 @@ struct AppFeatureTests { $0.appLaunchClient.iCloudRemoteChangeStream = { AsyncStream { $0.finish() } } $0.appSecurityClient.isRequireAuthOnLaunchEnabled = { true } $0.windowCaptureBlockerClient.enabledStream = { AsyncStream { $0.finish() } } + $0.generalSettingsClient.appearanceStream = { AsyncStream { $0.finish() } } } await store.send(.task) { @@ -83,6 +85,7 @@ struct AppFeatureTests { $0.appLaunchClient.requestNotificationAuthorization = { true } $0.appLaunchClient.iCloudRemoteChangeStream = { AsyncStream { $0.finish() } } $0.windowCaptureBlockerClient.enabledStream = { AsyncStream { $0.finish() } } + $0.generalSettingsClient.appearanceStream = { AsyncStream { $0.finish() } } // syncExpiryNotifications를 오버라이드하지 않는다 — 호출되면 @DependencyClient의 // unimplemented 클로저가 테스트를 실패시킨다. } @@ -129,6 +132,7 @@ struct AppFeatureTests { $0.appSecurityClient.isRequireAuthOnLaunchEnabled = { false } $0.appSecurityClient.inactivityTimeoutStream = { AsyncStream { $0.finish() } } $0.windowCaptureBlockerClient.enabledStream = { AsyncStream { $0.finish() } } + $0.generalSettingsClient.appearanceStream = { AsyncStream { $0.finish() } } } await store.send(.task) { @@ -180,6 +184,7 @@ struct AppFeatureTests { } } $0.windowCaptureBlockerClient.enabledStream = { AsyncStream { $0.finish() } } + $0.generalSettingsClient.appearanceStream = { AsyncStream { $0.finish() } } } await store.send(.task) { @@ -205,6 +210,7 @@ struct AppFeatureTests { continuation.finish() } } + $0.generalSettingsClient.appearanceStream = { AsyncStream { $0.finish() } } } await store.send(.task) { @@ -215,6 +221,31 @@ struct AppFeatureTests { } } + @Test("task는 화면 모드 설정 변경을 State에 반영한다") + func taskWatchesAppearanceSetting() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.appLaunchClient.hasCompletedOnboarding = { false } + $0.appLaunchClient.requestNotificationAuthorization = { true } + $0.appLaunchClient.iCloudRemoteChangeStream = { AsyncStream { $0.finish() } } + $0.windowCaptureBlockerClient.enabledStream = { AsyncStream { $0.finish() } } + $0.generalSettingsClient.appearanceStream = { + AsyncStream { continuation in + continuation.yield("dark") + continuation.finish() + } + } + } + + await store.send(.task) { + $0.onboarding = .init() + } + await store.receive(.appearanceChanged(.dark)) { + $0.appearance = .dark + } + } + @Test("iCloud 원격 변경은 debounce 후 만료 알림을 다시 동기화한다") func iCloudRemoteChangeSyncsExpiryNotifications() async { let clock = TestClock() diff --git a/Projects/DVPresentation/Tests/AppMenu/AppMenuCommandTests.swift b/Projects/DVPresentation/Tests/AppMenu/AppMenuCommandTests.swift new file mode 100644 index 00000000..cf4d9a83 --- /dev/null +++ b/Projects/DVPresentation/Tests/AppMenu/AppMenuCommandTests.swift @@ -0,0 +1,60 @@ +// Copyright © 2026 Devault. All rights reserved + +import Testing + +@testable import DVPresentation + +@Suite("AppMenuCommand") +struct AppMenuCommandTests { + + // MARK: - Catalog + + @Test("all은 New Secret·New Project·Lock·Settings 순서로 구성된다") + func allContainsCommandsInDisplayOrder() { + #expect(AppMenuCommand.all == [.newSecret, .newProject, .lockVault, .openSettings]) + } + + // MARK: - displayKeys (Shortcuts 설정 화면에 표시될 문자열) + + @Test("New Secret의 표시 단축키는 ⌘N이다") + func newSecretDisplayKeys() { + #expect(AppMenuCommand.newSecret.displayKeys == "⌘N") + } + + @Test("New Project의 표시 단축키는 ⇧⌘N이다 (Shift가 Command 앞)") + func newProjectDisplayKeys() { + #expect(AppMenuCommand.newProject.displayKeys == "⇧⌘N") + } + + @Test("Lock의 표시 단축키는 ⌃⌘L이다 (Control이 Command 앞)") + func lockDisplayKeys() { + #expect(AppMenuCommand.lockVault.displayKeys == "⌃⌘L") + } + + @Test("Settings의 표시 단축키는 ⌘,이다") + func settingsDisplayKeys() { + #expect(AppMenuCommand.openSettings.displayKeys == "⌘,") + } + + // MARK: - action (메뉴 선택 시 store로 보낼 액션) + + @Test("New Secret은 사이드바 추가 버튼 액션을 보낸다") + func newSecretAction() { + #expect(AppMenuCommand.newSecret.action == .main(.sidebar(.didTapAddButton))) + } + + @Test("New Project는 사이드바 프로젝트 추가 액션을 보낸다") + func newProjectAction() { + #expect(AppMenuCommand.newProject.action == .main(.sidebar(.didTapAddProject))) + } + + @Test("Lock은 잠금 액션을 보낸다") + func lockAction() { + #expect(AppMenuCommand.lockVault.action == .main(.didTapLock)) + } + + @Test("Settings는 사이드바 설정 액션을 보낸다") + func settingsAction() { + #expect(AppMenuCommand.openSettings.action == .main(.sidebar(.didTapSettings))) + } +} diff --git a/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift new file mode 100644 index 00000000..4dc51a25 --- /dev/null +++ b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift @@ -0,0 +1,29 @@ +// Copyright © 2026 Devault. All rights reserved + +import Testing + +@testable import DVPresentation + +@Suite("HelpMenuLink") +struct HelpMenuLinkTests { + + @Test("all은 Help·Privacy Policy·Send Feedback 순서로 구성된다") + func allContainsLinksInMenuOrder() { + #expect(HelpMenuLink.all == [.help, .privacyPolicy, .sendFeedback]) + } + + @Test("Help는 지원 사이트로 연결된다") + func helpURL() { + #expect(HelpMenuLink.help.url.absoluteString == "https://devault-support.notion.site/") + } + + @Test("Privacy Policy는 정책 사이트로 연결된다") + func privacyPolicyURL() { + #expect(HelpMenuLink.privacyPolicy.url.absoluteString == "https://devault-policy.notion.site/") + } + + @Test("Send Feedback은 팀 이메일 mailto로 연결된다") + func sendFeedbackURL() { + #expect(HelpMenuLink.sendFeedback.url.absoluteString == "mailto:devault.devteam@gmail.com") + } +} diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index eed97f9a..c9b5e65b 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -366,6 +366,94 @@ struct MainFeatureTests { } } + @Test("createSecretRequested는 그리드 없이 바로 생성하며 사이드바 선택 하이라이트와 조회 상태를 해제한다") + func createSecretRequestedOpensCreateSecretDirectly() async { + let secret = Secret( + id: UUID(), + name: "Test Token", + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + + // 사이드바에 필터가 선택돼 있고 시크릿을 조회 중인 상태 — App 메뉴 서브메뉴에서 바로 생성. + var initial = MainFeature.State() + initial.sidebar.selection = .filter(.starred) + initial.secretList.selectedSecretID = secret.id + initial.secretDetail = SecretDetailFeature.State(secret: secret) + + let store = TestStore(initialState: initial) { + MainFeature() + } + + await store.send(.createSecretRequested(.oauth)) { + $0.createSecret = CreateSecretFeature.State(secretType: .oauth) + $0.secretDetail = nil + $0.secretList.selectedSecretID = nil + } + // 생성 모드로 들어가면 사이드바는 어떤 행도 선택되지 않은 상태로 표시된다. + await store.receive(.sidebar(.setCreatingSecret(true))) { + $0.sidebar.mode = .creating(previous: $0.sidebar.selection) + } + } + + /// 설정 화면엔 사이드바·리스트가 없어, 진행하면 보이지 않는 상태만 바뀌고 설정을 닫을 때 튄다. + @Test("설정 화면에서는 createSecretRequested를 무시한다") + func createSecretRequestedIgnoredDuringSettings() async { + var initial = MainFeature.State() + initial.settings = .init() + + let store = TestStore(initialState: initial) { MainFeature() } + + // 아무 상태도 바꾸지 않고 효과도 없어야 한다(닫혀 있는 상태를 조용히 오염시키지 않는다). + await store.send(.createSecretRequested(.oauth)) + } + + /// 다른 진입점(⌘N·사이드바)은 확인을 거치는데 New▸만 조용히 버리면 작성 중 입력이 사라진다. + @Test("createSecretRequested는 작성 중인 폼이 있으면 덮어쓰지 않고 취소 확인을 거친다") + func createSecretRequestedAsksBeforeDiscardingForm() async { + var initial = MainFeature.State() + initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) + initial.sidebar.mode = .creating(previous: .filter(.all)) + + let store = TestStore(initialState: initial) { MainFeature() } + + // createSecret을 바꾸지 않고 pendingCreateType만 잡는다 — 바꿨다면 exhaustive가 실패한다. + await store.send(.createSecretRequested(.oauth)) { + $0.pendingCreateType = .oauth + } + await store.receive(.createSecret(.didTapCancel)) { + $0.createSecret?.alert = AlertState { + TextState("Discard changes?", bundle: .module) + } actions: { + ButtonState(role: .destructive, action: .confirmCancel) { + TextState("Discard", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("Keep editing", bundle: .module) + } + } + } + } + + @Test("취소를 확인하면 New▸로 요청한 타입으로 새 폼을 연다") + func confirmingDiscardOpensRequestedType() async { + var initial = MainFeature.State() + initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) + initial.pendingCreateType = .oauth + initial.sidebar.mode = .creating(previous: .filter(.all)) + + let store = TestStore(initialState: initial) { MainFeature() } + + await store.send(.createSecret(.delegate(.cancelled))) { + $0.pendingCreateType = nil + $0.createSecret = CreateSecretFeature.State(secretType: .oauth) + } + // 이미 creating 모드라 setCreatingSecret(true)는 상태를 바꾸지 않는다. + await store.receive(.sidebar(.setCreatingSecret(true))) + } + @Test("secretCreated는 생성 플로우를 닫고 사이드바 카운트를 다시 세게 한다") func secretCreatedClearsCreationFlow() async { let secretID = UUID() diff --git a/Projects/DVPresentation/Tests/Settings/About/AboutSettingsFeatureTests.swift b/Projects/DVPresentation/Tests/Settings/About/AboutSettingsFeatureTests.swift index ad3e1ca1..fa042db6 100644 --- a/Projects/DVPresentation/Tests/Settings/About/AboutSettingsFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Settings/About/AboutSettingsFeatureTests.swift @@ -21,4 +21,15 @@ struct AboutSettingsFeatureTests { $0.version = "2.0.0" } } + + @Test("didTapOpenSourceLicenses는 라이선스 sheet를 연다") + func didTapOpenSourceLicensesOpensSheet() async { + let store = TestStore(initialState: AboutSettingsFeature.State()) { + AboutSettingsFeature() + } + + await store.send(.didTapOpenSourceLicenses) { + $0.isShowingLicenses = true + } + } } diff --git a/Projects/DVPresentation/Tests/Settings/About/OpenSourceLicenseTests.swift b/Projects/DVPresentation/Tests/Settings/About/OpenSourceLicenseTests.swift new file mode 100644 index 00000000..c863fb75 --- /dev/null +++ b/Projects/DVPresentation/Tests/Settings/About/OpenSourceLicenseTests.swift @@ -0,0 +1,31 @@ +// Copyright © 2026 Devault. All rights reserved + +import Testing + +@testable import DVPresentation + +@Suite("OpenSourceLicense") +struct OpenSourceLicenseTests { + + @Test("all은 ComposableArchitecture와 Lottie를 순서대로 포함한다") + func allContainsDependencies() { + #expect(OpenSourceLicense.all == [.composableArchitecture, .lottie]) + } + + @Test("각 항목의 라이선스 전문이 번들에서 비어있지 않게 로드된다") + func textLoadsNonEmpty() { + for license in OpenSourceLicense.all { + #expect(!license.text.isEmpty) + } + } + + @Test("ComposableArchitecture 전문에 MIT 저작권 표기가 포함된다") + func tcaTextContainsCopyright() { + #expect(OpenSourceLicense.composableArchitecture.text.contains("Point-Free")) + } + + @Test("Lottie 전문에 Apache 저작권 표기가 포함된다") + func lottieTextContainsCopyright() { + #expect(OpenSourceLicense.lottie.text.contains("Airbnb")) + } +} diff --git a/Projects/DVPresentation/Tests/Settings/General/GeneralSettingsFeatureTests.swift b/Projects/DVPresentation/Tests/Settings/General/GeneralSettingsFeatureTests.swift index fd113145..d74f4382 100644 --- a/Projects/DVPresentation/Tests/Settings/General/GeneralSettingsFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Settings/General/GeneralSettingsFeatureTests.swift @@ -23,6 +23,7 @@ struct GeneralSettingsFeatureTests { } withDependencies: { $0.generalSettingsClient.launchAtLoginStatus = { .enabled } $0.generalSettingsClient.defaultEnvironment = { "prod" } + $0.generalSettingsClient.appearance = { "system" } } await store.send(.task) { @@ -41,6 +42,7 @@ struct GeneralSettingsFeatureTests { } withDependencies: { $0.generalSettingsClient.launchAtLoginStatus = { .notRegistered } $0.generalSettingsClient.defaultEnvironment = { "invalid" } + $0.generalSettingsClient.appearance = { "system" } } await store.send(.task) { @@ -48,6 +50,58 @@ struct GeneralSettingsFeatureTests { } } + @Test("화면 모드의 초기값은 System이다") + func appearanceInitiallyUsesSystem() { + #expect(GeneralSettingsFeature.State().appearance == .system) + } + + @Test("task는 저장된 화면 모드를 읽어온다") + func taskLoadsAppearance() async { + let store = TestStore(initialState: GeneralSettingsFeature.State()) { + GeneralSettingsFeature() + } withDependencies: { + $0.generalSettingsClient.launchAtLoginStatus = { .notRegistered } + $0.generalSettingsClient.defaultEnvironment = { "dev" } + $0.generalSettingsClient.appearance = { "dark" } + } + + await store.send(.task) { + $0.appearance = .dark + } + } + + @Test("저장된 화면 모드가 잘못되면 System을 사용한다") + func invalidAppearanceFallsBackToSystem() async { + var initialState = GeneralSettingsFeature.State() + initialState.appearance = .dark + let store = TestStore(initialState: initialState) { + GeneralSettingsFeature() + } withDependencies: { + $0.generalSettingsClient.launchAtLoginStatus = { .notRegistered } + $0.generalSettingsClient.defaultEnvironment = { "dev" } + $0.generalSettingsClient.appearance = { "invalid" } + } + + await store.send(.task) { + $0.appearance = .system + } + } + + @Test("화면 모드를 선택하면 저장한다") + func appearanceSelectionPersists() async { + let saved = LockIsolated("") + let store = TestStore(initialState: GeneralSettingsFeature.State()) { + GeneralSettingsFeature() + } withDependencies: { + $0.generalSettingsClient.setAppearance = { saved.setValue($0) } + } + + await store.send(.binding(.set(\.appearance, .dark))) { + $0.appearance = .dark + } + #expect(saved.value == "dark") + } + @Test("승인 대기 상태에서는 토글을 유지하고 승인 상태를 표시한다") func launchAtLoginKeepsToggleOnWhenApprovalIsRequired() async { let store = TestStore(initialState: GeneralSettingsFeature.State()) { diff --git a/Projects/DVPresentation/Tests/Settings/ICloud/ICloudSettingsFeatureTests.swift b/Projects/DVPresentation/Tests/Settings/ICloud/ICloudSettingsFeatureTests.swift index a0ed47f6..c552e5bd 100644 --- a/Projects/DVPresentation/Tests/Settings/ICloud/ICloudSettingsFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Settings/ICloud/ICloudSettingsFeatureTests.swift @@ -148,21 +148,18 @@ struct ICloudSettingsFeatureTests { } } - @Test("원격 변경이 감지되면 마지막 update 감지 시각을 저장한다") + @Test("원격 변경이 감지되면 표시용 마지막 update 감지 시각을 갱신한다(영속화는 AppFeature 단독)") func remoteChangeUpdatesLastUpdateDetectedAt() async { let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) - let savedDate = LockIsolated(nil) let store = TestStore(initialState: ICloudSettingsFeature.State()) { ICloudSettingsFeature() } withDependencies: { $0.date = .constant(fixedDate) - $0.iCloudSettingsClient.setLastUpdateDetectedAt = { savedDate.setValue($0) } } await store.send(.remoteChangeDetected) { $0.lastUpdateDetectedAt = fixedDate } - #expect(savedDate.value == fixedDate) } @Test("상태 새로고침에서 계정 오류가 확인되면 상태와 alert를 갱신한다") diff --git a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift index e4b9af28..925c7555 100644 --- a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -349,6 +349,31 @@ struct SidebarFeatureTests { } } + @Test("didConfirmRename에 빈 이름이면 alert를 띄우고 편집을 닫아 원래 이름으로 되돌린다") + func didConfirmRenameEmptyShowsAlertAndReverts() async { + let item = ProjectItem(id: UUID(), name: "Backend") + var state = SidebarFeature.State() + state.projectsState = .loaded([item]) + state.renamingProjectID = item.id + state.renameText = " " // 공백만 → trim 후 빈 문자열 + + let store = TestStore(initialState: state) { + SidebarFeature() + } + + await store.send(.didConfirmRename) { + $0.alert = AlertState { + TextState(String.module("Please enter a name.")) + } actions: { + ButtonState(role: .cancel) { TextState(String.module("OK")) } + } message: { + TextState(String.module("Project name can't be empty.")) + } + $0.renamingProjectID = nil + $0.renameText = "" + } + } + @Test("didCancelRename은 rename 상태를 초기화한다") func didCancelRenameResetsState() async { var state = SidebarFeature.State() diff --git a/Projects/DVStorage/DVStorage.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Projects/DVStorage/DVStorage.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Projects/DVStorage/DVStorage.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Projects/DVStorage/DVStorage.xcodeproj/xcshareddata/xcschemes/DVStorage.xcscheme b/Projects/DVStorage/DVStorage.xcodeproj/xcshareddata/xcschemes/DVStorage.xcscheme new file mode 100644 index 00000000..4565bed4 --- /dev/null +++ b/Projects/DVStorage/DVStorage.xcodeproj/xcshareddata/xcschemes/DVStorage.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Devault/Devault.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Projects/Devault/Devault.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Projects/Devault/Devault.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Projects/Devault/Devault.xcodeproj/xcshareddata/xcschemes/Devault.xcscheme b/Projects/Devault/Devault.xcodeproj/xcshareddata/xcschemes/Devault.xcscheme new file mode 100644 index 00000000..4ecb3cdb --- /dev/null +++ b/Projects/Devault/Devault.xcodeproj/xcshareddata/xcschemes/Devault.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Devault/Project.swift b/Projects/Devault/Project.swift index a913c40d..aa4677e0 100644 --- a/Projects/Devault/Project.swift +++ b/Projects/Devault/Project.swift @@ -12,6 +12,13 @@ import ProjectDescriptionHelpers /// TODO: 팀 시트를 전원에게 발급하면 이 분기와 아래 두 설정 딕셔너리를 제거한다. (#64) let isLocalSigning = Environment.localSigning.getBoolean(default: false) +/// CI에서 App Store 배포 아카이브를 만들 때 켠다. `ci_post_clone.sh`가 `TUIST_CI_SIGNING=1`로 설정한다. +let isCISigning = Environment.ciSigning.getBoolean(default: false) + +/// 아카이브 빌드 번호(CFBundleVersion). App Store는 업로드마다 고유·증가값을 요구하므로 +/// CI에선 `$CI_BUILD_NUMBER`를 주입하고, 로컬은 기본값 "1"을 쓴다. +let buildNumber = Environment.buildNumber.getString(default: "1") + let teamSigningSettings: SettingsDictionary = [ "ASSETCATALOG_COMPILER_APPICON_NAME": "Devault_IC", "DEVELOPMENT_TEAM": "UKY6HK6U6Y", @@ -21,6 +28,15 @@ let teamSigningSettings: SettingsDictionary = [ "CODE_SIGN_IDENTITY": "Apple Development", ] +/// CI 배포용. team 설정은 로컬 개발 탓에 서명 아이덴티티를 "Apple Development"로 고정하는데, +/// 그대로 아카이브하면 개발 인증서로 서명돼 App Store 업로드가 거부된다. 그래서 "Apple Distribution"으로 둔다. +let ciSigningSettings: SettingsDictionary = [ + "ASSETCATALOG_COMPILER_APPICON_NAME": "Devault_IC", + "DEVELOPMENT_TEAM": "UKY6HK6U6Y", + "CODE_SIGN_STYLE": "Automatic", + "CODE_SIGN_IDENTITY": "Apple Distribution", +] + let localSigningSettings: SettingsDictionary = [ "ASSETCATALOG_COMPILER_APPICON_NAME": "Devault_IC", "CODE_SIGN_STYLE": "Manual", @@ -48,8 +64,10 @@ let manualSigningSettings: SettingsDictionary = [ ] /// 로컬 서명이 가장 우선한다 — 자산을 받아둔 뒤에도 `generate-local`로 되돌릴 수 있어야 한다. +/// CI 서명은 그다음 — CI에서만 `TUIST_CI_SIGNING`이 켜진다. let signingSettings: SettingsDictionary = { if isLocalSigning { return localSigningSettings } + if isCISigning { return ciSigningSettings } if isManualSigning { return manualSigningSettings } return teamSigningSettings }() @@ -64,8 +82,20 @@ let project = Project.project( product: .app, bundleId: "com.devault.app", infoPlist: .extendingDefault(with: [ - "CFBundleDisplayName": .string("Devault"), + // 사용자에게 보이는 이름. 메뉴바는 CFBundleName, Finder·Launchpad는 CFBundleDisplayName을 쓰므로 둘 다 저장 + "CFBundleDisplayName": .string("DeVault"), + "CFBundleName": .string("DeVault"), + // Mac App Store 필수. 여기엔 주 카테고리(생산성)만 들어감 + "LSApplicationCategoryType": .string("public.app-category.productivity"), + // 마케팅 버전(기본값 "1.0"을 덮어쓴다). + "CFBundleShortVersionString": .string("1.0.0"), + "CFBundleVersion": .string(buildNumber), + // 표준 AES-GCM(CryptoKit)만 사용 → 수출 규정 면제 대상. + "ITSAppUsesNonExemptEncryption": .boolean(false), "NSFaceIDUsageDescription": .string("저장된 시크릿을 안전하게 보호하기 위해 Touch ID를 사용합니다."), + // About 패널·App Store에 노출. + "NSHumanReadableCopyright": .string("Copyright © 2026 Devault. All rights reserved."), + "CFBundleLocalizations": .array([.string("en"), .string("ko")]), ]), sources: .sources, resources: [.glob(pattern: "Resources/**", excluding: ["Resources/*.entitlements"])], diff --git a/Projects/Devault/Sources/Composition/NotificationDelegate.swift b/Projects/Devault/Sources/Composition/AppShell/NotificationDelegate.swift similarity index 100% rename from Projects/Devault/Sources/Composition/NotificationDelegate.swift rename to Projects/Devault/Sources/Composition/AppShell/NotificationDelegate.swift diff --git a/Projects/Devault/Sources/Composition/WindowCaptureBlocker.swift b/Projects/Devault/Sources/Composition/AppShell/WindowCaptureBlocker.swift similarity index 100% rename from Projects/Devault/Sources/Composition/WindowCaptureBlocker.swift rename to Projects/Devault/Sources/Composition/AppShell/WindowCaptureBlocker.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/AppLaunchClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/App/AppLaunchClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/AppLaunchClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/App/AppLaunchClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/AppLifecycleClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/App/AppLifecycleClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/AppLifecycleClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/App/AppLifecycleClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/WindowCapture/WindowCaptureBlockerClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/App/WindowCaptureBlockerClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/WindowCapture/WindowCaptureBlockerClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/App/WindowCaptureBlockerClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/LockClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Lock/LockClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/LockClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/Lock/LockClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/OnboardingClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Onboarding/OnboardingClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/OnboardingClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/Onboarding/OnboardingClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/ProjectClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Project/ProjectClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/ProjectClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/Project/ProjectClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/DetectionClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Secret/DetectionClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/DetectionClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/Secret/DetectionClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Secret/SecretClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/Secret/SecretClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretManagementClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Secret/SecretManagementClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/SecretManagementClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/Secret/SecretManagementClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/Settings/DataSettingsClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Settings/DataSettingsClient+Live.swift index 80e593f7..5dea0494 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/Settings/DataSettingsClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/Settings/DataSettingsClient+Live.swift @@ -9,7 +9,8 @@ extension DataSettingsClient: @retroactive DependencyKey { let useCase: any DataSettingsUseCase = DataSettingsUseCaseImpl( dataResetRepository: LiveRepositories.dataReset, settingsRepository: LiveRepositories.settings, - authenticateUseCase: LiveUseCases.authenticate + authenticateUseCase: LiveUseCases.authenticate, + expiryNotificationScheduler: LiveUseCases.expirySchedule ) return DataSettingsClient( diff --git a/Projects/Devault/Sources/Composition/Dependencies/Settings/GeneralSettingsClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Settings/GeneralSettingsClient+Live.swift index a749a88f..4fd472b5 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/Settings/GeneralSettingsClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/Settings/GeneralSettingsClient+Live.swift @@ -27,6 +27,15 @@ extension GeneralSettingsClient: @retroactive DependencyKey { }, setDefaultEnvironment: { rawValue in useCase.setDefaultEnvironment(rawValue) + }, + appearance: { + useCase.appearance() + }, + setAppearance: { rawValue in + useCase.setAppearance(rawValue) + }, + appearanceStream: { + useCase.appearanceStream() } ) }() diff --git a/Projects/Devault/Sources/Composition/Dependencies/Settings/ICloudSettingsClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Settings/ICloudSettingsClient+Live.swift index 556778e2..f7b4ae97 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/Settings/ICloudSettingsClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/Settings/ICloudSettingsClient+Live.swift @@ -38,9 +38,6 @@ extension ICloudSettingsClient: @retroactive DependencyKey { lastUpdateDetectedAt: { useCase.lastUpdateDetectedAt() }, - setLastUpdateDetectedAt: { date in - useCase.setLastUpdateDetectedAt(date) - }, remoteChangeStream: { useCase.remoteChangeStream() }, diff --git a/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/Sidebar/SidebarClient+Live.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift rename to Projects/Devault/Sources/Composition/Dependencies/Sidebar/SidebarClient+Live.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/LiveRepositories.swift b/Projects/Devault/Sources/Composition/Graph/LiveRepositories.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/LiveRepositories.swift rename to Projects/Devault/Sources/Composition/Graph/LiveRepositories.swift diff --git a/Projects/Devault/Sources/Composition/LiveRepositoryProxies.swift b/Projects/Devault/Sources/Composition/Graph/LiveRepositoryProxies.swift similarity index 100% rename from Projects/Devault/Sources/Composition/LiveRepositoryProxies.swift rename to Projects/Devault/Sources/Composition/Graph/LiveRepositoryProxies.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/LiveServices.swift b/Projects/Devault/Sources/Composition/Graph/LiveServices.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/LiveServices.swift rename to Projects/Devault/Sources/Composition/Graph/LiveServices.swift diff --git a/Projects/Devault/Sources/Composition/LiveStorage.swift b/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift similarity index 100% rename from Projects/Devault/Sources/Composition/LiveStorage.swift rename to Projects/Devault/Sources/Composition/Graph/LiveStorage.swift diff --git a/Projects/Devault/Sources/Composition/Dependencies/LiveUseCases.swift b/Projects/Devault/Sources/Composition/Graph/LiveUseCases.swift similarity index 100% rename from Projects/Devault/Sources/Composition/Dependencies/LiveUseCases.swift rename to Projects/Devault/Sources/Composition/Graph/LiveUseCases.swift diff --git a/Projects/Devault/Sources/DevaultApp.swift b/Projects/Devault/Sources/DevaultApp.swift index 9f31e393..23c168ca 100644 --- a/Projects/Devault/Sources/DevaultApp.swift +++ b/Projects/Devault/Sources/DevaultApp.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import UserNotifications @@ -14,6 +15,8 @@ struct DevaultApp: App { init() { UNUserNotificationCenter.current().delegate = NotificationDelegate.shared + // 단일 창 앱이라 창 탭이 불필요 + NSWindow.allowsAutomaticWindowTabbing = false } var body: some Scene { @@ -34,6 +37,9 @@ extension DevaultApp { width: WindowLayoutMetrics.windowDefaultWidth, height: WindowLayoutMetrics.windowDefaultHeight ) + .commands { + AppCommands(store: store) + } } } @@ -56,5 +62,7 @@ private struct DevaultRootView: View { isEnabled: store.isWindowCaptureBlockingEnabled ) ) + // nil이면 macOS 시스템 설정을 따르고, 그 외에는 앱 전체를 라이트/다크로 고정한다. + .preferredColorScheme(store.appearance.colorScheme) } } diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh new file mode 100755 index 00000000..bd248282 --- /dev/null +++ b/ci_scripts/ci_post_clone.sh @@ -0,0 +1,39 @@ +#!/bin/sh + +# ci_post_clone.sh +# Xcode Cloud가 소스를 클론한 직후, 의존성 해석·빌드 전에 실행된다. +# +# 이 레포는 .xcworkspace/.xcodeproj를 커밋하지 않고 Tuist로 생성한다(.gitignore 참고). +# 따라서 Xcode Cloud가 빌드를 시작하려면 여기서 워크스페이스를 먼저 만들어야 한다. +# +# mise로 .mise.toml에 핀된 Tuist(4.191.0)를 설치 → tuist install(SPM) → tuist generate +# +# 로컬 setup.sh와 같은 도구/버전을 쓰므로 CI와 로컬 빌드가 어긋나지 않는다. + +set -e + +# Xcode Cloud는 ci_scripts/에서 스크립트를 실행한다. .mise.toml·Tuist 매니페스트가 있는 +# 레포 루트로 이동해야 mise가 핀된 버전을, tuist가 프로젝트를 올바르게 읽는다. +cd "$CI_PRIMARY_REPOSITORY_PATH" + +echo "=== [ci_post_clone] mise 설치 ===" +curl -fsSL https://mise.run | sh +export PATH="$HOME/.local/bin:$PATH" +export MISE_YES=1 # 비대화형 CI: 확인 프롬프트 자동 승인 +mise trust "$CI_PRIMARY_REPOSITORY_PATH/.mise.toml" # 처음 클론한 config를 신뢰 처리 + +echo "=== [ci_post_clone] 핀된 도구 설치 (.mise.toml → Tuist 4.191.0) ===" +mise install + +echo "=== [ci_post_clone] SPM 의존성 해석 (tuist install) ===" +mise exec -- tuist install + +# TUIST_CI_SIGNING=1 → Project.swift가 CI 배포 서명(Apple Distribution, Automatic)을 선택한다. +# 로컬 개발용 "Apple Development" 고정을 그대로 쓰면 App Store 아카이브가 개발 인증서로 +# 서명되어 업로드에서 거부되므로, CI에서는 반드시 이 분기로 생성한다. +# TUIST_BUILD_NUMBER=$CI_BUILD_NUMBER → 업로드마다 고유·증가하는 빌드 번호를 CFBundleVersion에 주입. +# Xcode Cloud가 제공하는 값이며, 없으면(로컬 등) Project.swift 기본값 "1"로 떨어진다. +echo "=== [ci_post_clone] 워크스페이스 생성 (배포 서명 + 빌드 번호 ${CI_BUILD_NUMBER:-1}) ===" +TUIST_CI_SIGNING=1 TUIST_BUILD_NUMBER="${CI_BUILD_NUMBER:-1}" mise exec -- tuist generate --no-open + +echo "=== [ci_post_clone] 완료 ==="