Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
03f0fd2
[#64] feat: iCloud 계정 상태 확인 및 동기화 설정 저장 기능 추가
dlguszoo Jul 30, 2026
e923b0a
[#64] chore: Devault 앱 타겟 Bundle ID 및 iCloud entitlements 설정
dlguszoo Jul 30, 2026
3f86628
[#64] fix: iCloud entitlement 추가로 인한 SwiftData 초기화 실패 방지
dlguszoo Jul 30, 2026
7026851
[#64] feat: Lock 화면에 실제 Touch ID 인증 연결
dlguszoo Jul 30, 2026
086f6cf
[#64] feat: 온보딩에 실제 Touch ID 인증과 iCloud 동기화 연결
dlguszoo Jul 30, 2026
8173d43
[#64] refactor: SwiftData 모델 @Attribute(.unique) 제거
dlguszoo Jul 30, 2026
6c05b05
[#64] refactor: SwiftData to-one 관계 optional로 변경
dlguszoo Jul 30, 2026
5598e9a
[#64] refactor: SwiftData 모델 non-optional 프로퍼티에 선언부 기본값 추가
dlguszoo Jul 30, 2026
6bcf58c
[#64] feat: LocalStorage에서 CloudKit 미러링 활성화
dlguszoo Jul 30, 2026
c71ac4e
[#64] feat: 마스터 암호화 키 iCloud Keychain 동기화
dlguszoo Jul 30, 2026
7e8d627
[#64] feat: iCloud 동기화 설정 NSUbiquitousKeyValueStore로 전환
dlguszoo Jul 30, 2026
4df3308
[#64] fix: isICloudSyncEnabled 플래그가 실제 CloudKit 미러링을 제어하지 않던 문제 수정
dlguszoo Jul 30, 2026
2698c0f
[#64] chore: TODO 주석 및 정리
dlguszoo Jul 30, 2026
59fab04
[#64] add: app icon 추가. chore: lottie 파일 위치 수정
dlguszoo Jul 30, 2026
0d19438
[#64] feat: 앱 아이콘 추가 및 sidebar 앱 로고뷰 구현
dlguszoo Jul 30, 2026
cf6b72c
[#64] fix: public extension 접근 제어 가이드 위반 수정
dlguszoo Aug 3, 2026
a384934
[#64] fix: CloudKit 컨테이너 식별자 중복 제거
dlguszoo Aug 3, 2026
f409515
[#64] fix: Keychain 마스터 키 접근 정책 강화
dlguszoo Aug 3, 2026
0adc5b8
[#64] refactor: iCloud 동기화 설정을 로컬 저장으로 되돌림
dlguszoo Aug 3, 2026
680c27c
[#64] fix: CloudKit 동기화 지연 시 Secret 조회/연결 안정화
dlguszoo Aug 3, 2026
6b84665
[#64] fix: CloudKit 관계 optional 크래시 수정
dlguszoo Aug 3, 2026
821137d
[#64] refactor: 인증 실패 알럿 공통화
dlguszoo Aug 3, 2026
58d51e2
[#64] chore: CloudKit 디버그 로그 launch argument 추가
dlguszoo Aug 3, 2026
371256b
[#64] fix: 코드 리뷰 반영 — CloudKit 동기화 안정성/보안 강화
dlguszoo Aug 8, 2026
a441e77
[#64] fix: ICloudAccountStatus에 configurationUnavailable case 추가
dlguszoo Aug 8, 2026
02a0a21
[#64] chore: 팀 시트 없는 환경에서도 앱 빌드 가능하도록 서명 설정 분리
doyeonk429 Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
[tools]
tuist = "4.191.0"

# 이 저장소 안에서만 scripts/의 명령을 접두사 없이 실행할 수 있게 한다. (예: generate-local)
[env]
_.path = ["{{config_root}}/scripts"]
7 changes: 7 additions & 0 deletions Projects/DVCore/Sources/ICloudContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright © 2026 Devault. All rights reserved

/// Devault.entitlements에 등록된 iCloud(CloudKit) 컨테이너 식별자.
/// entitlements 파일 자체는 plist라 이 상수를 참조할 수 없으니, 값을 바꿀 땐 함께 맞춰줘야 한다.
public enum ICloudContainer {
public static let identifier = "iCloud.com.devault.app"
}
22 changes: 11 additions & 11 deletions Projects/DVCore/Sources/Logger/DVLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,56 +52,56 @@ public protocol DVLogger {
static var defaultCategory: LogCategory { get }
}

public extension DVLogger {
static var defaultCategory: LogCategory { .general }
extension DVLogger {
public static var defaultCategory: LogCategory { .general }
}

// MARK: - Level Methods

public extension DVLogger {
extension DVLogger {
/// 상세 디버그 로그. Debug 빌드 전용.
static func debug(_ message: @autoclosure () -> String, category: LogCategory? = nil,
public static func debug(_ message: @autoclosure () -> String, category: LogCategory? = nil,
file: String = #fileID, function: String = #function, line: Int = #line) {
#if DEBUG
emit(.debug, "⚪️", message(), category ?? defaultCategory, file, function, line)
#endif
}

/// 일반 정보 로그. Debug 빌드 전용.
static func info(_ message: @autoclosure () -> String, category: LogCategory? = nil,
public static func info(_ message: @autoclosure () -> String, category: LogCategory? = nil,
file: String = #fileID, function: String = #function, line: Int = #line) {
#if DEBUG
emit(.info, "🔵", message(), category ?? defaultCategory, file, function, line)
#endif
}

/// 경고 로그. Debug 빌드 전용.
static func warn(_ message: @autoclosure () -> String, category: LogCategory? = nil,
public static func warn(_ message: @autoclosure () -> String, category: LogCategory? = nil,
file: String = #fileID, function: String = #function, line: Int = #line) {
#if DEBUG
emit(.default, "🟡", message(), category ?? defaultCategory, file, function, line)
#endif
}

/// 에러 로그. 항상 기록되며 Release 에선 마스킹된다.
static func error(_ message: @autoclosure () -> String, category: LogCategory? = nil,
public static func error(_ message: @autoclosure () -> String, category: LogCategory? = nil,
file: String = #fileID, function: String = #function, line: Int = #line) {
emit(.error, "🔴", message(), category ?? defaultCategory, file, function, line)
}

/// 치명적 에러 로그. 항상 기록되며 Release 에선 마스킹된다.
static func critical(_ message: @autoclosure () -> String, category: LogCategory? = nil,
public static func critical(_ message: @autoclosure () -> String, category: LogCategory? = nil,
file: String = #fileID, function: String = #function, line: Int = #line) {
emit(.fault, "🟣", message(), category ?? defaultCategory, file, function, line)
}
}

// MARK: - measure

public extension DVLogger {
extension DVLogger {
/// 동기 블록의 실행 시간을 `⏱ … - 12.34ms` 로 로깅한다. Release 에선 측정 없이 실행만 한다.
@discardableResult
static func measure<T>(_ label: String, category: LogCategory? = nil,
public static func measure<T>(_ label: String, category: LogCategory? = nil,
file: String = #fileID, function: String = #function, line: Int = #line,
_ work: () throws -> T) rethrows -> T {
#if DEBUG
Expand All @@ -117,7 +117,7 @@ public extension DVLogger {

/// ``measure(_:category:file:function:line:_:)`` 의 async 버전.
@discardableResult
static func measure<T>(_ label: String, category: LogCategory? = nil,
public static func measure<T>(_ label: String, category: LogCategory? = nil,
file: String = #fileID, function: String = #function, line: Int = #line,
_ work: () async throws -> T) async rethrows -> T {
#if DEBUG
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ enum SecretFetchDescriptorBuilder {
case let .project(projectID):
return #Predicate<SwiftDataModel.Secret> { secret in
secret.deletedAt == nil &&
secret.projectLinks.contains { link in
link.project.id == projectID
} &&
secret.projectLinks?.contains { link in
link.projectID == projectID
} == true &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
(!hasSecretType || secret.secretType == secretType) &&
(!hasService || secret.service == service) &&
(!hasEnvironment || secret.environment == environment)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,16 @@ public actor SecretRepositoryImpl: SecretRepository {
do {
let descriptor = SecretFetchDescriptorBuilder.make(from: query)
let localSecrets = try modelContext.fetch(descriptor)
let domainSecrets = try localSecrets.map { try $0.toDomain() }
let domainSecrets = localSecrets.compactMap { localSecret -> DVDomain.Secret? in
do {
return try localSecret.toDomain()
} catch {
// CloudKit 동기화 지연 등으로 payload가 아직 도착하지 않았을 수 있어 목록에서만 건너뜀
// 반복적으로 찍히면 진짜 손상된 레코드일 수 있으니 확인 필요
Log.warn("Secret(\(localSecret.id)) 변환 실패, 목록에서 제외: \(error)", category: .storage)
return nil
}
}
return InMemorySecretQueryFilter.apply(query, to: domainSecrets)
} catch let error as SecretRepositoryError {
throw error
Expand Down Expand Up @@ -282,7 +291,7 @@ public actor SecretRepositoryImpl: SecretRepository {

apply(patch, to: localSecret)

let currentIDs = Set(localSecret.projects.map(\.id))
let currentIDs = Set((localSecret.projectLinks ?? []).map(\.projectID))
let desiredIDs = Set(projectIDs)

for projectID in desiredIDs.subtracting(currentIDs) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,31 @@ import DVDomain
public struct SettingsRepositoryImpl: SettingsRepository, @unchecked Sendable {

private let defaults: UserDefaults
private let ubiquitousStore: NSUbiquitousKeyValueStore

public init(defaults: UserDefaults = .standard) {
public init(
defaults: UserDefaults = .standard,
ubiquitousStore: NSUbiquitousKeyValueStore = .default
) {
self.defaults = defaults
self.ubiquitousStore = ubiquitousStore
}

// hasCompletedOnboarding은 기기별로 Touch ID 확인이 필요하므로 로컬 UserDefaults로 관리
public func hasCompletedOnboarding() -> Bool {
defaults.bool(forKey: .hasCompletedOnboarding)
}

public func setOnboardingCompleted() {
defaults.set(true, forKey: .hasCompletedOnboarding)
}

// iCloud 동기화 사용 여부는 독립적으로 켜야 하므로 로컬 UserDefaults로 관리
public func isICloudSyncEnabled() -> Bool {
defaults.bool(forKey: .isICloudSyncEnabled)
}

public func setICloudSyncEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: .isICloudSyncEnabled)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation

// 기기 간 동기화가 필요한 설정이 생기면 여기에 케이스를 추가한다.
//enum UbiquitousStoreKey: String {}
//
//extension NSUbiquitousKeyValueStore {
// func bool(forKey key: UbiquitousStoreKey) -> Bool {
// bool(forKey: key.rawValue)
// }
//
// func set(_ value: Bool, forKey key: UbiquitousStoreKey) {
// set(value, forKey: key.rawValue)
// }
//}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Foundation

enum UserDefaultsKey: String {
case hasCompletedOnboarding
case isICloudSyncEnabled
}

extension UserDefaults {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright © 2026 Devault. All rights reserved

import CloudKit
import DVCore
import DVDomain

public struct CloudKitAccountServiceImpl: ICloudAccountService {

private let container: CKContainer

/// - Parameter containerIdentifier: entitlements에 등록된 iCloud 컨테이너 식별자
public init(containerIdentifier: String) {
self.container = CKContainer(identifier: containerIdentifier)
}

public func fetchAccountStatus() async -> ICloudAccountStatus {
do {
switch try await container.accountStatus() {
case .available:
return .available
case .noAccount:
return .noAccount
case .restricted:
return .restricted
case .temporarilyUnavailable:
return .temporarilyUnavailable
case .couldNotDetermine:
return .couldNotDetermine
@unknown default:
return .couldNotDetermine
}
} catch let error as CKError {
switch error.code {
case .networkUnavailable, .networkFailure:
return .networkUnavailable
case .notAuthenticated:
return .noAccount
case .badContainer, .missingEntitlement:
// container identifier 오타나 entitlement 누락 등 배포 설정 문제. 재시도로는 해결되지 않으므로 로그 추가
Log.error("iCloud 컨테이너 설정 오류: \(error)", category: .security)
return .configurationUnavailable
default:
return .couldNotDetermine
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
return .couldNotDetermine
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ import Security

struct KeychainKeyStore: Sendable {
private let service: String

/// Keychain generic password item을 구분할 service namespace를 설정한다.
init(service: String) {
self.service = service
}

/// tag에 해당하는 symmetric key를 조회하고, 없으면 새 key를 생성해 Keychain에 저장한다.
func getOrCreateSymmetricKey(tag: String) throws -> SymmetricKey {
if let existing = try loadKeyData(tag: tag) {
return SymmetricKey(data: existing)
}

let generated = try generateKeyData()
let resolved = try saveOrLoadExistingKeyData(generated, tag: tag)
return SymmetricKey(data: resolved)
Expand All @@ -29,7 +29,7 @@ struct KeychainKeyStore: Sendable {
guard let data = try loadKeyData(tag: tag) else {
throw SecretCryptoError.keyUnavailable
}

return SymmetricKey(data: data)
}
}
Expand All @@ -38,6 +38,7 @@ extension KeychainKeyStore {
/// Keychain에서 tag에 해당하는 raw key Data를 조회한다.
private func loadKeyData(tag: String) throws -> Data? {
var query = keyQuery(tag: tag)
query[kSecAttrSynchronizable] = kSecAttrSynchronizableAny

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 \
  'getOrCreateSymmetricKey|loadKeyData|saveOrLoadExistingKeyData|SecItem(Update|Add|CopyMatching|Delete)|kSecAttrSynchronizable' \
  Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift

Repository: DevaultProject/Devault-macOS

Length of output: 2799


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant KeychainKeyStore implementation without executing repository code.
sed -n '1,120p' Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift

# Locate all callers of getOrCreateSymmetricKey to assess write path impact.
rg -n -C 4 'getOrCreateSymmetricKey\(' Projects/DVData/Sources/ServiceImpl

Repository: DevaultProject/Devault-macOS

Length of output: 5925


기존 비동기화 Keychain 항목을 동기화 항목으로 마이그레이션하세요.

kSecAttrSynchronizableAny로 조회한 값이 있으면 getOrCreateSymmetricKey가 새 항목을 저장하지 않고 반환합니다. 기존 설치에서는 동기화용 암호 키가 iCloud Keychain에 저장되지 않아 새 기기 복호화는 실패합니다. 비동기화 항목을 감지하면 키 데이터를 보존한 상태로 SecItemUpdate 또는 새로운 동기화 항목으로 다시 저장해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift`
at line 41, Update getOrCreateSymmetricKey to detect an existing
non-synchronizable Keychain item returned by the kSecAttrSynchronizableAny query
and migrate it to a synchronizable item while preserving the key data, using
SecItemUpdate or re-saving a new item with the synchronizable attribute before
returning it.

let attributes: [CFString: Any] = [
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
Expand Down Expand Up @@ -65,7 +66,8 @@ extension KeychainKeyStore {

let attributes: [CFString: Any] = [
kSecValueData: data,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked,
kSecAttrSynchronizable: true,
]

var addQuery = query
Expand Down Expand Up @@ -93,6 +95,7 @@ extension KeychainKeyStore {
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: tag,
kSecUseDataProtectionKeychain: true,
]
}

Expand Down
18 changes: 15 additions & 3 deletions Projects/DVData/Sources/Storage/Local/LocalStorage.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright © 2026 Devault. All rights reserved

import DVCore
import SwiftData

public final class LocalStorage {
Expand All @@ -9,11 +10,22 @@ public final class LocalStorage {
self.modelContainer = modelContainer
}

public static func makeDefault() throws -> LocalStorage {
let configuration = ModelConfiguration(isStoredInMemoryOnly: false)
public static func makeDefault(iCloudSyncEnabled: Bool) throws -> LocalStorage {
let syncedConfiguration = ModelConfiguration(
"Synced",
schema: Schema.syncedSchema,
isStoredInMemoryOnly: false,
cloudKitDatabase: iCloudSyncEnabled ? .private(ICloudContainer.identifier) : .none
)
Comment on lines +13 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# appSchema에 BackupRecord가 포함되는지와 저장소 구성을 확인합니다.
rg -n -C 4 --glob '*.swift' 'appSchema|BackupRecord|ModelConfiguration|filePath' \
  Projects/DVData/Sources Projects/Devault/Sources

Repository: DevaultProject/Devault-macOS

Length of output: 5923


BackupRecord.filePath와 iCloud 동기화 경계를 분리하세요.

Schema.appSchemaBackupRecord를 포함하고 있고 그 스키마에 모두 cloudKit private database를 적용하므로, 로컬 절대 경로가 사용자의 동기화 데이터베이스에 포함될 수 있습니다. filePath는 로컬 전용 저장소/모델로 분리하고, 동기화 백업 식별이 필요하면 기기 독립 식별자만 사용하세요.

📍 Affects 2 files
  • Projects/DVData/Sources/Storage/Local/LocalStorage.swift#L13-L17 (this comment)
  • Projects/DVData/Sources/Storage/Local/Models/BackupRecord.swift#L8-L15
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DVData/Sources/Storage/Local/LocalStorage.swift` around lines 13 -
17, Projects/DVData/Sources/Storage/Local/LocalStorage.swift:13-17에서
makeDefault의 동기화 모델 구성을 BackupRecord.filePath가 포함되지 않는 로컬 전용 저장소/모델과 분리하세요.
Projects/DVData/Sources/Storage/Local/Models/BackupRecord.swift:8-15의 filePath는
동기화 스키마에서 제외하고, 동기화 백업 식별이 필요한 경우 로컬 절대 경로 대신 기기 독립 식별자만 사용하도록 관련 모델 참조를 조정하세요.

let localOnlyConfiguration = ModelConfiguration(
"LocalOnly",
schema: Schema.localOnlySchema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none
)
let modelContainer = try ModelContainer(
for: Schema.appSchema,
configurations: configuration
configurations: syncedConfiguration, localOnlyConfiguration
)

return LocalStorage(modelContainer: modelContainer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import SwiftData

extension SwiftDataModel {
@Model final class AppAuditLog {
@Attribute(.unique) var id: UUID
var eventType: String
var actorContext: String
var occurredAt: Date
var id: UUID = UUID()
var eventType: String = ""
var actorContext: String = ""
var occurredAt: Date = Date()

init(
id: UUID = UUID(),
Expand Down
18 changes: 18 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/AppSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ enum SwiftDataModel { }
extension Schema {
private static let schemaVersion: Schema.Version = Version(1, 0, 0)

/// iCloud와 동기화되는 모델. CloudKit private database로 미러링 됨
static let syncedSchema = Schema([
SwiftDataModel.Project.self,
SwiftDataModel.Secret.self,
SwiftDataModel.SecretProjectLink.self,
SwiftDataModel.SecretPayload.self,
SwiftDataModel.SecretMetadata.self,
SwiftDataModel.SecretAuditLog.self,
SwiftDataModel.AppAuditLog.self,
], version: schemaVersion)

/// 기기 로컬에만 저장되는 모델.
/// BackupRecord.filePath는 해당 기기의 절대 경로라 다른 기기에서는 의미가 없고,
/// 사용자명 등 로컬 경로 정보가 그대로 iCloud에 올라가면 안 되므로 동기화 스코프에서 제외
static let localOnlySchema = Schema([
SwiftDataModel.BackupRecord.self,
], version: schemaVersion)

static let appSchema = Schema([
SwiftDataModel.Project.self,
SwiftDataModel.Secret.self,
Expand Down
16 changes: 8 additions & 8 deletions Projects/DVData/Sources/Storage/Local/Models/BackupRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import SwiftData

extension SwiftDataModel {
@Model final class BackupRecord {
@Attribute(.unique) var id: UUID
var fileName: String
var filePath: String
var backupScope: String
var hasIndependentPassword: Bool
var keyTag: String
var totalSecrets: Int
var createdAt: Date
var id: UUID = UUID()
var fileName: String = ""
var filePath: String = ""
var backupScope: String = ""
var hasIndependentPassword: Bool = false
var keyTag: String = ""
var totalSecrets: Int = 0
var createdAt: Date = Date()

init(
id: UUID = UUID(),
Expand Down
Loading