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] 완료 ==="