diff --git a/Projects/DVData/Project.swift b/Projects/DVData/Project.swift index fbc8488..f0f2179 100644 --- a/Projects/DVData/Project.swift +++ b/Projects/DVData/Project.swift @@ -7,7 +7,11 @@ let project = Project.project( .target( name: DVModule.DVData.name, product: Project.product, - sources: .sources + sources: .sources, + dependencies: [ + .domain(), + .core(), + ] ), ] ) diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift new file mode 100644 index 0000000..2cbc73b --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift @@ -0,0 +1,35 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +/// SwiftData FetchDescriptor로 처리하기 애매한 조건을 Domain Secret 배열에서 후처리하는 필터. 현재는 searchText만 담당 +enum InMemorySecretQueryFilter { + static func apply(_ query: SecretQuery, to secrets: [DVDomain.Secret]) -> [DVDomain.Secret] { + secrets.filter { matchesSearchText(query.searchText, secret: $0) } + } + + private static func matchesSearchText(_ searchText: String?, secret: DVDomain.Secret) -> Bool { + guard let searchText else { + return true + } + + let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !keyword.isEmpty else { + return true + } + + let fields = [ + secret.name, + secret.secretType, + secret.subType, + secret.service, + secret.environment, + secret.memo, + ] + + return fields.contains { + $0?.localizedCaseInsensitiveContains(keyword) == true + } + } +} diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift new file mode 100644 index 0000000..24bf826 --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift @@ -0,0 +1,102 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import SwiftData + +/// Domain의 SecretQuery를 SwiftData의 FetchDescriptor로 바꾸는 타입 +enum SecretFetchDescriptorBuilder { + static func make(from query: SecretQuery) -> FetchDescriptor { + var descriptor = FetchDescriptor( + predicate: predicate(from: query), + sortBy: sortDescriptors(from: query.sort) + ) + descriptor.includePendingChanges = true + return descriptor + } + + private static func predicate(from query: SecretQuery) -> Predicate { + let hasSecretType = !(query.secretType?.isEmpty ?? true) + let secretType = query.secretType ?? "" + let hasService = !(query.service?.isEmpty ?? true) + let service = query.service ?? "" + let hasEnvironment = !(query.environment?.isEmpty ?? true) + let environment = query.environment ?? "" + + switch query.collection { + case .all: + return #Predicate { secret in + secret.deletedAt == nil && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case .liked: + return #Predicate { secret in + secret.deletedAt == nil && + secret.liked && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case let .expired(referenceDate): + return #Predicate { secret in + secret.deletedAt == nil && + secret.expiresAt != nil && + secret.expiresAt! < referenceDate && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case .deleted: + return #Predicate { secret in + secret.deletedAt != nil && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case let .project(projectID): + return #Predicate { secret in + secret.deletedAt == nil && + secret.projectLinks.contains { link in + link.project.id == projectID + } && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + } + } + + private static func sortDescriptors( + from sort: SecretQuery.Sort + ) -> [SortDescriptor] { + switch sort { + case .recentlyAdded: + return [ + SortDescriptor(\.createdAt, order: .reverse), + SortDescriptor(\.updatedAt, order: .reverse), + ] + case .oldestFirst: + return [ + SortDescriptor(\.createdAt, order: .forward), + SortDescriptor(\.updatedAt, order: .forward), + ] + case .expiringSoon: + return [ + SortDescriptor(\.expiresAt, order: .forward), + SortDescriptor(\.updatedAt, order: .reverse), + ] + case .nameAscending: + return [ + SortDescriptor(\.name, order: .forward), + SortDescriptor(\.updatedAt, order: .reverse), + ] + case .nameDescending: + return [ + SortDescriptor(\.name, order: .reverse), + SortDescriptor(\.updatedAt, order: .reverse), + ] + } + } +} diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift new file mode 100644 index 0000000..d0bafb4 --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift @@ -0,0 +1,208 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import SwiftData + +@ModelActor +public actor SecretRepositoryImpl: SecretRepository { + public func create(_ secret: DVDomain.Secret) async throws -> DVDomain.Secret { + do { + let localSecret = SwiftDataModel.Secret( + id: secret.id, + name: secret.name, + secretType: secret.secretType, + subType: secret.subType, + service: secret.service, + environment: secret.environment, + expiresAt: secret.expiresAt, + memo: secret.memo, + liked: secret.liked, + deletedAt: secret.deletedAt, + createdAt: secret.createdAt, + updatedAt: secret.updatedAt + ) + + let localPayload = SwiftDataModel.SecretPayload( + encryptedData: secret.payload.encryptedData, + keyTag: secret.payload.keyTag, + schemaVersion: secret.payload.schemaVersion, + secret: localSecret + ) + localSecret.payload = localPayload + + modelContext.insert(localSecret) + modelContext.insert(localPayload) + + if let metadata = secret.metadata { + let localMetadata = SwiftDataModel.SecretMetadata( + metadataJSON: metadata.metadataJSON, + schemaVersion: metadata.schemaVersion, + secret: localSecret + ) + localSecret.metadata = localMetadata + modelContext.insert(localMetadata) + } + + try modelContext.save() + + return try localSecret.toDomain() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + public func fetch(id: UUID) async throws -> DVDomain.Secret? { + do { + guard let localSecret = try fetchLocalSecret(id: id) else { + return nil + } + return try localSecret.toDomain() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + /// SecretFetchDescriptorBuilder로 원하는 조건으로 fetch 후 InMemorySecretQueryFilter로 searchText 보정 + public func fetch(_ query: SecretQuery) async throws -> [DVDomain.Secret] { + do { + let descriptor = SecretFetchDescriptorBuilder.make(from: query) + let localSecrets = try modelContext.fetch(descriptor) + let domainSecrets = try localSecrets.map { try $0.toDomain() } + return InMemorySecretQueryFilter.apply(query, to: domainSecrets) + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + /// SecretPatch 적용하여 update + public func patch(id: UUID, with patch: SecretPatch) async throws -> DVDomain.Secret { + do { + guard let localSecret = try fetchLocalSecret(id: id) else { + throw SecretRepositoryError.notFound(id: id) + } + + apply(patch, to: localSecret) + try modelContext.save() + + return try localSecret.toDomain() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + public func delete(id: UUID) async throws { + do { + guard let localSecret = try fetchLocalSecret(id: id) else { + throw SecretRepositoryError.notFound(id: id) + } + + modelContext.delete(localSecret) + try modelContext.save() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } +} + +extension SecretRepositoryImpl { + private func fetchLocalSecret(id: UUID) throws -> SwiftDataModel.Secret? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first + } + + /// SecretPatch를 SwiftData model에 반영 + private func apply(_ patch: SecretPatch, to secret: SwiftDataModel.Secret) { + if case let .set(name) = patch.name { + secret.name = name + } + if case let .set(secretType) = patch.secretType { + secret.secretType = secretType + } + if case let .set(subType) = patch.subType { + secret.subType = subType + } + if case let .set(service) = patch.service { + secret.service = service + } + if case let .set(environment) = patch.environment { + secret.environment = environment + } + if case let .set(expiresAt) = patch.expiresAt { + secret.expiresAt = expiresAt + } + if case let .set(memo) = patch.memo { + secret.memo = memo + } + if case let .set(liked) = patch.liked { + secret.liked = liked + } + if case let .set(deletedAt) = patch.deletedAt { + secret.deletedAt = deletedAt + } + if case let .set(updatedAt) = patch.updatedAt { + secret.updatedAt = updatedAt + } + if case let .set(payload) = patch.payload { + apply(payload, to: secret) + } + if case let .set(metadata) = patch.metadata { + apply(metadata, to: secret) + } + } + + /// payload가 있으면 업데이트하고, 없으면 새 payload를 만든다. + private func apply(_ payload: DVDomain.SecretPayload, to secret: SwiftDataModel.Secret) { + if let localPayload = secret.payload { + localPayload.encryptedData = payload.encryptedData + localPayload.keyTag = payload.keyTag + localPayload.schemaVersion = payload.schemaVersion + } else { + let localPayload = SwiftDataModel.SecretPayload( + encryptedData: payload.encryptedData, + keyTag: payload.keyTag, + schemaVersion: payload.schemaVersion, + secret: secret + ) + secret.payload = localPayload + modelContext.insert(localPayload) + } + } + + /// metadata가 nil이면 기존 metadata를 삭제하고, 값이 있으면 업데이트 또는 생성한다. + private func apply(_ metadata: DVDomain.SecretMetadata?, to secret: SwiftDataModel.Secret) { + guard let metadata else { + if let localMetadata = secret.metadata { + modelContext.delete(localMetadata) + } + secret.metadata = nil + return + } + + if let localMetadata = secret.metadata { + localMetadata.metadataJSON = metadata.metadataJSON + localMetadata.schemaVersion = metadata.schemaVersion + } else { + let localMetadata = SwiftDataModel.SecretMetadata( + metadataJSON: metadata.metadataJSON, + schemaVersion: metadata.schemaVersion, + secret: secret + ) + secret.metadata = localMetadata + modelContext.insert(localMetadata) + } + } +} diff --git a/Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift b/Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift new file mode 100644 index 0000000..ed195c8 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift @@ -0,0 +1,45 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import LocalAuthentication + +public struct LocalUserAuthenticationServiceImpl: UserAuthenticationService { + /// LocalAuthentication 기반 사용자 인증 구현체를 생성한다. + public init() {} + + /// Touch ID 또는 시스템 암호로 현재 사용자를 인증한다. + public func authenticate(reason: String) async throws { + let context = LAContext() + context.touchIDAuthenticationAllowableReuseDuration = 0 + + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + throw UserAuthenticationError.unavailable + } + + try await withCheckedThrowingContinuation { continuation in + context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: reason + ) { success, error in + if success { + continuation.resume() + return + } + + if let error = error as? LAError { + switch error.code { + case .userCancel, .systemCancel, .appCancel: + continuation.resume(throwing: UserAuthenticationError.cancelled) + default: + continuation.resume(throwing: UserAuthenticationError.failed) + } + return + } + + continuation.resume(throwing: UserAuthenticationError.failed) + } + } + } +} diff --git a/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift new file mode 100644 index 0000000..f8294a6 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift @@ -0,0 +1,27 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +struct SecretMetadataJSONCoder: Sendable { + /// Metadata content를 JSON Data로 직렬화한다. + func encode(_ metadata: Metadata) throws -> Data { + do { + return try JSONEncoder().encode(metadata) + } catch { + throw SecretCryptoError.encodingFailed + } + } + + /// JSON Data를 요청한 metadata content 타입으로 역직렬화한다. + func decode( + _ data: Data, + as type: Metadata.Type + ) throws -> Metadata { + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw SecretCryptoError.decodingFailed + } + } +} diff --git a/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift new file mode 100644 index 0000000..90b978c --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift @@ -0,0 +1,27 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +struct SecretPayloadJSONCoder: Sendable { + /// Payload content를 JSON Data로 직렬화한다. + func encode(_ payload: Payload) throws -> Data { + do { + return try JSONEncoder().encode(payload) + } catch { + throw SecretCryptoError.encodingFailed + } + } + + /// JSON Data를 요청한 payload content 타입으로 역직렬화한다. + func decode( + _ data: Data, + as type: Payload.Type + ) throws -> Payload { + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw SecretCryptoError.decodingFailed + } + } +} diff --git a/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift new file mode 100644 index 0000000..2354c04 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift @@ -0,0 +1,112 @@ +// Copyright © 2026 Devault. All rights reserved + +import CryptoKit +import DVDomain +import Foundation +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 data = try loadKeyData(tag: tag) { + return SymmetricKey(data: data) + } + + let data = try generateKeyData() + try saveKeyData(data, tag: tag) + return SymmetricKey(data: data) + } + + /// tag에 해당하는 symmetric key를 조회하고, 없으면 keyUnavailable 오류를 던진다. + func getSymmetricKey(tag: String) throws -> SymmetricKey { + guard let data = try loadKeyData(tag: tag) else { + throw SecretCryptoError.keyUnavailable + } + + return SymmetricKey(data: data) + } +} + +extension KeychainKeyStore { + /// Keychain에서 tag에 해당하는 raw key Data를 조회한다. + private func loadKeyData(tag: String) throws -> Data? { + var query = keyQuery(tag: tag) + let attributes: [CFString: Any] = [ + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + attributes.forEach { query[$0.key] = $0.value } + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + // '키가 없음' -> 암호화 시점에서 key 새로 생성, 복호화 시점에서 keyUnavailable + if status == errSecItemNotFound { + return nil + } + + guard status == errSecSuccess, let data = result as? Data else { + throw SecretCryptoError.keychainFailure(status: status) + } + + return data + } + + /// raw key Data를 Keychain에 저장하고, 기존 item이 있으면 갱신한다. + private func saveKeyData(_ data: Data, tag: String) throws { + let query = keyQuery(tag: tag) + + let attributes: [CFString: Any] = [ + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + + var addQuery = query + attributes.forEach { addQuery[$0.key] = $0.value } + + let status = SecItemAdd(addQuery as CFDictionary, nil) + + if status == errSecDuplicateItem { + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + guard updateStatus == errSecSuccess else { + throw SecretCryptoError.keychainFailure(status: updateStatus) + } + return + } + + guard status == errSecSuccess else { + throw SecretCryptoError.keychainFailure(status: status) + } + } + + /// service와 tag로 Keychain key item을 식별하는 기본 query를 만든다. + private func keyQuery(tag: String) -> [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: tag, + ] + } + + /// AES-256 symmetric key로 사용할 32바이트 랜덤 Data를 생성한다. + private func generateKeyData() throws -> Data { + var bytes = [UInt8](repeating: 0, count: 32) + let count = bytes.count + let status = bytes.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, count, $0.baseAddress!) + } + + guard status == errSecSuccess else { + throw SecretCryptoError.keyUnavailable + } + + return Data(bytes) + } +} diff --git a/Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift b/Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift new file mode 100644 index 0000000..6c5f990 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift @@ -0,0 +1,88 @@ +// Copyright © 2026 Devault. All rights reserved + +import CryptoKit +import DVDomain +import Foundation + +public struct SecretCryptoServiceImpl: SecretCryptoService { + private let masterKeyTag: String + private let keyStore: KeychainKeyStore + private let payloadJSONCoder: SecretPayloadJSONCoder + private let metadataJSONCoder: SecretMetadataJSONCoder + + /// Keychain key tag와 service namespace를 설정해 SecretCryptoService 구현체를 생성한다. + public init( + masterKeyTag: String = "com.devault.masterKey.v1", + keychainService: String = "com.devault.secret" + ) { + self.masterKeyTag = masterKeyTag + self.keyStore = KeychainKeyStore(service: keychainService) + self.payloadJSONCoder = SecretPayloadJSONCoder() + self.metadataJSONCoder = SecretMetadataJSONCoder() + } + + /// Payload content를 JSON으로 직렬화한 뒤 AES-GCM으로 암호화해 저장용 SecretPayload를 만든다. + public func encryptPayload( + _ payload: Payload + ) async throws -> DVDomain.SecretPayload { + do { + let key = try keyStore.getOrCreateSymmetricKey(tag: masterKeyTag) + let json = try payloadJSONCoder.encode(payload) + // nonce + ciphertext + authentication tag 생성 + let sealedBox = try AES.GCM.seal(json, using: key) + + // 3가지를 하나의 Data로 합침 -> DB에 저장 + guard let combined = sealedBox.combined else { + throw SecretCryptoError.encryptionFailed + } + + return DVDomain.SecretPayload( + encryptedData: combined, + keyTag: masterKeyTag, + schemaVersion: Payload.schemaVersion + ) + } catch let error as SecretCryptoError { + throw error + } catch { + throw SecretCryptoError.encryptionFailed + } + } + + /// 저장된 SecretPayload를 keyTag로 복호화한 뒤 요청한 payload content 타입으로 역직렬화한다. + public func decryptPayload( + _ payload: DVDomain.SecretPayload, + as type: Payload.Type + ) async throws -> Payload { + do { + // 새 key를 만들어도 기존 암호문은 열릴 수 없으므로 key가 없을 때 새 키를 만들지 않음 + let key = try keyStore.getSymmetricKey(tag: payload.keyTag) + let sealedBox = try AES.GCM.SealedBox(combined: payload.encryptedData) + // key가 맞는지, authentication tag가 유효한지 확인 + let json = try AES.GCM.open(sealedBox, using: key) + return try payloadJSONCoder.decode(json, as: type) + } catch let error as SecretCryptoError { + throw error + } catch { + throw SecretCryptoError.decryptionFailed + } + } + + /// Metadata content를 JSON Data로 직렬화해 저장용 SecretMetadata를 만든다. + public func encodeMetadata( + _ metadata: Metadata + ) throws -> DVDomain.SecretMetadata { + let json = try metadataJSONCoder.encode(metadata) + return DVDomain.SecretMetadata( + metadataJSON: json, + schemaVersion: Metadata.schemaVersion + ) + } + + /// 저장된 SecretMetadata의 JSON Data를 요청한 metadata content 타입으로 역직렬화한다. + public func decodeMetadata( + _ metadata: DVDomain.SecretMetadata, + as type: Metadata.Type + ) throws -> Metadata { + try metadataJSONCoder.decode(metadata.metadataJSON, as: type) + } +} diff --git a/Projects/DVData/Sources/Storage/Local/LocalStorage.swift b/Projects/DVData/Sources/Storage/Local/LocalStorage.swift new file mode 100644 index 0000000..f8a300f --- /dev/null +++ b/Projects/DVData/Sources/Storage/Local/LocalStorage.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftData + +public final class LocalStorage { + public static let shared = LocalStorage() + + private init() { } + + public lazy var modelContainer: ModelContainer = { + let configuration = ModelConfiguration(isStoredInMemoryOnly: false) + + do { + return try ModelContainer( + for: Schema.appSchema, + configurations: configuration + ) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } + }() +} diff --git a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift index 6861923..0385f11 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import SwiftData import Foundation @@ -36,7 +37,7 @@ extension SwiftDataModel { } init( - secretId: UUID = UUID(), + id: UUID = UUID(), name: String, secretType: String, subType: String? = nil, @@ -50,7 +51,7 @@ extension SwiftDataModel { updatedAt: Date? = nil ) { let initialCreatedAt = createdAt - self.secretId = secretId + self.id = id self.name = name self.secretType = secretType self.subType = subType @@ -69,3 +70,28 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.Secret { + func toDomain() throws -> DVDomain.Secret { + guard let payload else { + throw SecretRepositoryError.corruptedStorage + } + + return DVDomain.Secret( + id: id, + name: name, + secretType: secretType, + subType: subType, + service: service, + environment: environment, + expiresAt: expiresAt, + memo: memo, + liked: liked, + deletedAt: deletedAt, + createdAt: createdAt, + updatedAt: updatedAt, + payload: payload.toDomain(), + metadata: metadata?.toDomain() + ) + } +} diff --git a/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift b/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift index 284b29f..2cfa7ab 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import Foundation import SwiftData @@ -25,3 +26,12 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.SecretMetadata { + func toDomain() -> DVDomain.SecretMetadata { + DVDomain.SecretMetadata( + metadataJSON: metadataJSON, + schemaVersion: schemaVersion + ) + } +} diff --git a/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift b/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift index f503ce0..80813e7 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import Foundation import SwiftData @@ -28,3 +29,13 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.SecretPayload { + func toDomain() -> DVDomain.SecretPayload { + DVDomain.SecretPayload( + encryptedData: encryptedData, + keyTag: keyTag, + schemaVersion: schemaVersion + ) + } +} diff --git a/Projects/DVDesign/Project.swift b/Projects/DVDesign/Project.swift index e220b87..83c6401 100644 --- a/Projects/DVDesign/Project.swift +++ b/Projects/DVDesign/Project.swift @@ -8,7 +8,10 @@ let project = Project.project( name: DVModule.DVDesign.name, product: Project.product, sources: .sources, - resources: .default + resources: .default, + dependencies: [ + .core(), + ] ), .sampleApp( name: DVModule.DVDesign.name, diff --git a/Projects/DVDomain/Project.swift b/Projects/DVDomain/Project.swift index f6761d3..5ddeada 100644 --- a/Projects/DVDomain/Project.swift +++ b/Projects/DVDomain/Project.swift @@ -7,11 +7,10 @@ let project = Project.project( .target( name: DVModule.DVDomain.name, product: Project.product, - sources: .sources - ), - .tests( - name: DVModule.DVDomain.name, - dependencies: [DVModule.DVDomain.dependency] + sources: .sources, + dependencies: [ + .core(), + ] ), ] ) diff --git a/Projects/DVDomain/Sources/Entity/Secret.swift b/Projects/DVDomain/Sources/Entity/Secret.swift new file mode 100644 index 0000000..a807d61 --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/Secret.swift @@ -0,0 +1,52 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct Secret: Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var secretType: String + public var subType: String? + public var service: String? + public var environment: String? + public var expiresAt: Date? + public var memo: String? + public var liked: Bool + public var deletedAt: Date? + public var createdAt: Date + public var updatedAt: Date + public var payload: SecretPayload + public var metadata: SecretMetadata? + + public init( + id: UUID, + name: String, + secretType: String, + subType: String? = nil, + service: String? = nil, + environment: String? = nil, + expiresAt: Date? = nil, + memo: String? = nil, + liked: Bool = false, + deletedAt: Date? = nil, + createdAt: Date, + updatedAt: Date, + payload: SecretPayload, + metadata: SecretMetadata? = nil + ) { + self.id = id + self.name = name + self.secretType = secretType + self.subType = subType + self.service = service + self.environment = environment + self.expiresAt = expiresAt + self.memo = memo + self.liked = liked + self.deletedAt = deletedAt + self.createdAt = createdAt + self.updatedAt = updatedAt + self.payload = payload + self.metadata = metadata + } +} diff --git a/Projects/DVDomain/Sources/Entity/SecretMetadata.swift b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift new file mode 100644 index 0000000..97b229d --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift @@ -0,0 +1,16 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct SecretMetadata: Equatable, Sendable { + public var metadataJSON: Data + public var schemaVersion: Int + + public init( + metadataJSON: Data, + schemaVersion: Int + ) { + self.metadataJSON = metadataJSON + self.schemaVersion = schemaVersion + } +} diff --git a/Projects/DVDomain/Sources/Entity/SecretPayload.swift b/Projects/DVDomain/Sources/Entity/SecretPayload.swift new file mode 100644 index 0000000..a20f4fc --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/SecretPayload.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct SecretPayload: Equatable, Sendable { + public var encryptedData: Data + public var keyTag: String + public var schemaVersion: Int + + public init( + encryptedData: Data, + keyTag: String, + schemaVersion: Int + ) { + self.encryptedData = encryptedData + self.keyTag = keyTag + self.schemaVersion = schemaVersion + } +} diff --git a/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift b/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift new file mode 100644 index 0000000..2b645bd --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public enum SecretRepositoryError: Error, Equatable, Sendable { + case notFound(id: UUID) + case duplicateID(id: UUID) + case invalidQuery + case invalidPatch + case corruptedStorage + case storageUnavailable + case persistenceFailed +} diff --git a/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift new file mode 100644 index 0000000..f3a5107 --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol SecretRepository: Sendable { + func create(_ secret: Secret) async throws -> Secret + func fetch(id: UUID) async throws -> Secret? + func fetch(_ query: SecretQuery) async throws -> [Secret] + func patch(id: UUID, with patch: SecretPatch) async throws -> Secret + func delete(id: UUID) async throws +} diff --git a/Projects/DVDomain/Sources/Repository/SecretPatch.swift b/Projects/DVDomain/Sources/Repository/SecretPatch.swift new file mode 100644 index 0000000..406bb8a --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/SecretPatch.swift @@ -0,0 +1,52 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// Secret의 일부 필드 변경 요청을 표현합니다. +public struct SecretPatch: Equatable, Sendable { + public var name: PatchField + public var secretType: PatchField + public var subType: PatchField + public var service: PatchField + public var environment: PatchField + public var expiresAt: PatchField + public var memo: PatchField + public var liked: PatchField + public var deletedAt: PatchField + public var payload: PatchField + public var metadata: PatchField + public var updatedAt: PatchField + + public init( + name: PatchField = .unchanged, + secretType: PatchField = .unchanged, + subType: PatchField = .unchanged, + service: PatchField = .unchanged, + environment: PatchField = .unchanged, + expiresAt: PatchField = .unchanged, + memo: PatchField = .unchanged, + liked: PatchField = .unchanged, + deletedAt: PatchField = .unchanged, + payload: PatchField = .unchanged, + metadata: PatchField = .unchanged, + updatedAt: PatchField = .unchanged + ) { + self.name = name + self.secretType = secretType + self.subType = subType + self.service = service + self.environment = environment + self.expiresAt = expiresAt + self.memo = memo + self.liked = liked + self.deletedAt = deletedAt + self.payload = payload + self.metadata = metadata + self.updatedAt = updatedAt + } +} + +public enum PatchField: Equatable, Sendable { + case unchanged + case set(Value) +} diff --git a/Projects/DVDomain/Sources/Repository/SecretQuery.swift b/Projects/DVDomain/Sources/Repository/SecretQuery.swift new file mode 100644 index 0000000..effe32b --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/SecretQuery.swift @@ -0,0 +1,47 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// Secret 목록 조회 범위, 필터, 검색어, 정렬 조건을 표현합니다. +public struct SecretQuery: Equatable, Sendable { + public var collection: Collection // 어디 탭/사이드 바 범위에서 볼 것인가 + public var secretType: String? // 어떤 종류의 Secret인가 + public var service: String? // 어떤 서비스인가 + public var environment: String? // 어떤 실행 환경인가 + public var searchText: String? // 사용자가 입력한 텍스트 검색어 + public var sort: Sort // 어떤 순서로 볼 것인가 + + public init( + collection: Collection = .all, + secretType: String? = nil, + service: String? = nil, + environment: String? = nil, + searchText: String? = nil, + sort: Sort = .recentlyAdded + ) { + self.collection = collection + self.secretType = secretType + self.service = service + self.environment = environment + self.searchText = searchText + self.sort = sort + } +} + +public extension SecretQuery { + enum Collection: Equatable, Sendable { + case all + case liked + case expired(referenceDate: Date) + case deleted + case project(id: UUID) + } + + enum Sort: Equatable, Sendable { + case recentlyAdded + case oldestFirst + case expiringSoon + case nameAscending + case nameDescending + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift new file mode 100644 index 0000000..8ef8336 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct APIKeyMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var scope: String? + + public init(scope: String? = nil) { + self.scope = scope + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift new file mode 100644 index 0000000..92623cd --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift @@ -0,0 +1,25 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct DatabaseMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var host: String? + public var port: Int? + public var databaseName: String? + public var username: String? + public var sslRequired: Bool? + + public init( + host: String? = nil, + port: Int? = nil, + databaseName: String? = nil, + username: String? = nil, + sslRequired: Bool? = nil + ) { + self.host = host + self.port = port + self.databaseName = databaseName + self.username = username + self.sslRequired = sslRequired + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift new file mode 100644 index 0000000..7d24bbe --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 평문 JSON으로 저장 가능한 Secret metadata content가 따라야 하는 Domain 계약입니다. +public protocol SecretMetadataContent: Codable, Sendable { + static var schemaVersion: Int { get } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift new file mode 100644 index 0000000..97c10bc --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct LicenseKeyMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var licenseType: String? + public var registrationEmail: String? + public var orderNumber: String? + public var website: String? + + public init( + licenseType: String? = nil, + registrationEmail: String? = nil, + orderNumber: String? = nil, + website: String? = nil + ) { + self.licenseType = licenseType + self.registrationEmail = registrationEmail + self.orderNumber = orderNumber + self.website = website + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift new file mode 100644 index 0000000..c94ed36 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct OAuthClientMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var redirectUri: String? + public var scopes: String? + + public init(redirectUri: String? = nil, scopes: String? = nil) { + self.redirectUri = redirectUri + self.scopes = scopes + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift new file mode 100644 index 0000000..cb82961 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSHKeyMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var publicKey: String? + public var keyType: String? + public var host: String? + public var username: String? + + public init( + publicKey: String? = nil, + keyType: String? = nil, + host: String? = nil, + username: String? = nil + ) { + self.publicKey = publicKey + self.keyType = keyType + self.host = host + self.username = username + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift new file mode 100644 index 0000000..0a29ee0 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSLCertMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var domain: String? + public var issuer: String? + public var renewCommand: String? + + public init( + domain: String? = nil, + issuer: String? = nil, + renewCommand: String? = nil + ) { + self.domain = domain + self.issuer = issuer + self.renewCommand = renewCommand + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift new file mode 100644 index 0000000..2658250 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct ServiceAccountMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var projectId: String? + public var accountEmail: String? + public var authority: String? + + public init( + projectId: String? = nil, + accountEmail: String? = nil, + authority: String? = nil + ) { + self.projectId = projectId + self.accountEmail = accountEmail + self.authority = authority + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift new file mode 100644 index 0000000..997e972 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct APIKeyPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var value: String + + public init(value: String) { + self.value = value + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift new file mode 100644 index 0000000..12d27a4 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct CustomPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var value: String + + public init(value: String) { + self.value = value + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift new file mode 100644 index 0000000..e1a0146 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct DatabasePayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var linkString: String + + public init(linkString: String) { + self.linkString = linkString + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift new file mode 100644 index 0000000..3ea6d03 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct EnvSetPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var content: String + + public init(content: String) { + self.content = content + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift b/Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift new file mode 100644 index 0000000..0b20e47 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 암호화 대상 Secret payload content가 따라야 하는 Domain 계약입니다. +public protocol SecretPayloadData: Codable, Sendable { + static var schemaVersion: Int { get } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift new file mode 100644 index 0000000..baea0c1 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct LicenseKeyPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var licenseKey: String + + public init(licenseKey: String) { + self.licenseKey = licenseKey + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift new file mode 100644 index 0000000..99d2a20 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct OAuthClientPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var clientId: String + public var clientSecret: String + + public init(clientId: String, clientSecret: String) { + self.clientId = clientId + self.clientSecret = clientSecret + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift new file mode 100644 index 0000000..3cde697 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSHKeyPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var privateKey: String + public var passphrase: String? + + public init(privateKey: String, passphrase: String? = nil) { + self.privateKey = privateKey + self.passphrase = passphrase + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift new file mode 100644 index 0000000..661af5d --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSLCertPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var certificate: String + public var privateKey: String + public var certificateChain: String? + + public init( + certificate: String, + privateKey: String, + certificateChain: String? = nil + ) { + self.certificate = certificate + self.privateKey = privateKey + self.certificateChain = certificateChain + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift new file mode 100644 index 0000000..2399d6c --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct ServiceAccountPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var credentialJSON: String + + public init(credentialJSON: String) { + self.credentialJSON = credentialJSON + } +} diff --git a/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift b/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift new file mode 100644 index 0000000..fa5a179 --- /dev/null +++ b/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift @@ -0,0 +1,10 @@ +// Copyright © 2026 Devault. All rights reserved + +public enum SecretCryptoError: Error, Equatable, Sendable { + case keyUnavailable + case keychainFailure(status: Int32) + case encryptionFailed + case decryptionFailed + case encodingFailed + case decodingFailed +} diff --git a/Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift b/Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift new file mode 100644 index 0000000..eb3da8b --- /dev/null +++ b/Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift @@ -0,0 +1,7 @@ +// Copyright © 2026 Devault. All rights reserved + +public enum UserAuthenticationError: Error, Equatable, Sendable { + case unavailable + case cancelled + case failed +} diff --git a/Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift b/Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift new file mode 100644 index 0000000..228eebb --- /dev/null +++ b/Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +/// Secret payload 암호화와 metadata 인코딩을 수행하는 서비스 프로토콜입니다. +public protocol SecretCryptoService: Sendable { + func encryptPayload( + _ payload: Payload + ) async throws -> SecretPayload + + func decryptPayload( + _ payload: SecretPayload, + as type: Payload.Type + ) async throws -> Payload + + func encodeMetadata( + _ metadata: Metadata + ) throws -> SecretMetadata + + func decodeMetadata( + _ metadata: SecretMetadata, + as type: Metadata.Type + ) throws -> Metadata +} diff --git a/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift b/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift new file mode 100644 index 0000000..dab2db4 --- /dev/null +++ b/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift @@ -0,0 +1,6 @@ +// Copyright © 2026 Devault. All rights reserved + +/// 현재 사용자가 민감 작업을 수행할 수 있는지 로컬 인증으로 확인하는 서비스입니다. +public protocol UserAuthenticationService: Sendable { + func authenticate(reason: String) async throws +} diff --git a/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift new file mode 100644 index 0000000..0682ad9 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift @@ -0,0 +1,35 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 아직 저장되지 않은 Secret 초안 정보를 표현합니다. +public struct SecretDraft: Equatable, Sendable { + public var name: String + public var secretType: String + public var subType: String? + public var service: String? + public var environment: String? + public var expiresAt: Date? + public var memo: String? + public var liked: Bool + + public init( + name: String, + secretType: String, + subType: String? = nil, + service: String? = nil, + environment: String? = nil, + expiresAt: Date? = nil, + memo: String? = nil, + liked: Bool = false + ) { + self.name = name + self.secretType = secretType + self.subType = subType + self.service = service + self.environment = environment + self.expiresAt = expiresAt + self.memo = memo + self.liked = liked + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift new file mode 100644 index 0000000..5a8a30f --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift @@ -0,0 +1,38 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public enum SecretUseCaseError: Error, Equatable, Sendable { + case invalidName + case invalidSecretType + case secretNotFound(id: UUID) + case repositoryFailure(SecretRepositoryError) + case cryptoFailure(SecretCryptoError) + case authenticationFailure(UserAuthenticationError) + case unexpected +} + +extension SecretUseCaseError { + static func map(_ error: Error) -> SecretUseCaseError { + if let error = error as? SecretUseCaseError { + return error + } + + if let error = error as? SecretRepositoryError { + if case let .notFound(id) = error { + return .secretNotFound(id: id) + } + return .repositoryFailure(error) + } + + if let error = error as? SecretCryptoError { + return .cryptoFailure(error) + } + + if let error = error as? UserAuthenticationError { + return .authenticationFailure(error) + } + + return .unexpected + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift new file mode 100644 index 0000000..c8b5f46 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift @@ -0,0 +1,81 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct CreateSecretUseCaseImpl: CreateSecretUseCase { + private let repository: any SecretRepository + private let cryptoService: any SecretCryptoService + private let idGenerator: @Sendable () -> UUID + private let dateProvider: @Sendable () -> Date + + public init( + repository: any SecretRepository, + cryptoService: any SecretCryptoService, + idGenerator: @escaping @Sendable () -> UUID = { UUID() }, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.cryptoService = cryptoService + self.idGenerator = idGenerator + self.dateProvider = dateProvider + } + + public func execute( + draft: SecretDraft, + payload: Payload + ) async throws -> Secret { + do { + try SecretUseCaseHelper.validateDraft(draft) + let now = dateProvider() + let encryptedPayload = try await cryptoService.encryptPayload(payload) + let secret = Secret( + id: idGenerator(), + name: draft.name, + secretType: draft.secretType, + subType: draft.subType, + service: draft.service, + environment: draft.environment, + expiresAt: draft.expiresAt, + memo: draft.memo, + liked: draft.liked, + createdAt: now, + updatedAt: now, + payload: encryptedPayload + ) + return try await repository.create(secret) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func execute( + draft: SecretDraft, + payload: Payload, + metadata: Metadata + ) async throws -> Secret { + do { + try SecretUseCaseHelper.validateDraft(draft) + let now = dateProvider() + let encryptedPayload = try await cryptoService.encryptPayload(payload) + let encodedMetadata = try cryptoService.encodeMetadata(metadata) + let secret = Secret( + id: idGenerator(), + name: draft.name, + secretType: draft.secretType, + subType: draft.subType, + service: draft.service, + environment: draft.environment, + expiresAt: draft.expiresAt, + memo: draft.memo, + liked: draft.liked, + createdAt: now, + updatedAt: now, + payload: encryptedPayload, + metadata: encodedMetadata + ) + return try await repository.create(secret) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift new file mode 100644 index 0000000..51c4112 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift @@ -0,0 +1,50 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct DeleteSecretUseCaseImpl: DeleteSecretUseCase { + private let repository: any SecretRepository + private let dateProvider: @Sendable () -> Date + + public init( + repository: any SecretRepository, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.dateProvider = dateProvider + } + + public func softDelete(id: UUID) async throws -> Secret { + do { + let now = dateProvider() + let patch = SecretPatch( + deletedAt: .set(now), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func restore(id: UUID) async throws -> Secret { + do { + let now = dateProvider() + let patch = SecretPatch( + deletedAt: .set(nil), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func permanentlyDelete(id: UUID) async throws { + do { + try await repository.delete(id: id) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift new file mode 100644 index 0000000..d08aead --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift @@ -0,0 +1,50 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct FetchSecretUseCaseImpl: FetchSecretUseCase { + private let repository: any SecretRepository + private let cryptoService: any SecretCryptoService + private let authenticationService: any UserAuthenticationService + + public init( + repository: any SecretRepository, + cryptoService: any SecretCryptoService, + authenticationService: any UserAuthenticationService + ) { + self.repository = repository + self.cryptoService = cryptoService + self.authenticationService = authenticationService + } + + public func fetch(id: UUID) async throws -> Secret? { + do { + return try await repository.fetch(id: id) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func fetch(query: SecretQuery) async throws -> [Secret] { + do { + return try await repository.fetch(query) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func revealPayload( + id: UUID, + as type: Payload.Type + ) async throws -> Payload { + do { + try await authenticationService.authenticate(reason: "Reveal secret payload") + guard let secret = try await repository.fetch(id: id) else { + throw SecretUseCaseError.secretNotFound(id: id) + } + return try await cryptoService.decryptPayload(secret.payload, as: type) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift new file mode 100644 index 0000000..5a101b0 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift @@ -0,0 +1,75 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct PatchSecretUseCaseImpl: PatchSecretUseCase { + private let repository: any SecretRepository + private let cryptoService: any SecretCryptoService + private let dateProvider: @Sendable () -> Date + + public init( + repository: any SecretRepository, + cryptoService: any SecretCryptoService, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.cryptoService = cryptoService + self.dateProvider = dateProvider + } + + public func patch(id: UUID, with patch: SecretPatch) async throws -> Secret { + do { + let patch = SecretUseCaseHelper.settingUpdatedAtIfNeeded(patch, now: dateProvider()) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func updatePayload( + id: UUID, + payload: Payload + ) async throws -> Secret { + do { + let encryptedPayload = try await cryptoService.encryptPayload(payload) + let now = dateProvider() + let patch = SecretPatch( + payload: .set(encryptedPayload), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func updateMetadata( + id: UUID, + metadata: Metadata + ) async throws -> Secret { + do { + let encodedMetadata = try cryptoService.encodeMetadata(metadata) + let now = dateProvider() + let patch = SecretPatch( + metadata: .set(encodedMetadata), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func removeMetadata(id: UUID) async throws -> Secret { + do { + let now = dateProvider() + let patch = SecretPatch( + metadata: .set(nil), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift new file mode 100644 index 0000000..10494f9 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift @@ -0,0 +1,25 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +enum SecretUseCaseHelper { + /// Secret 초안의 기본 필수값을 검증합니다. + static func validateDraft(_ draft: SecretDraft) throws { + guard !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SecretUseCaseError.invalidName + } + + guard !draft.secretType.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SecretUseCaseError.invalidSecretType + } + } + + /// patch에 updatedAt이 없으면 전달받은 시각으로 채웁니다. + static func settingUpdatedAtIfNeeded(_ patch: SecretPatch, now: Date) -> SecretPatch { + var next = patch + if case .unchanged = next.updatedAt { + next.updatedAt = .set(now) + } + return next + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift new file mode 100644 index 0000000..cc6357e --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift @@ -0,0 +1,14 @@ +// Copyright © 2026 Devault. All rights reserved + +public protocol CreateSecretUseCase: Sendable { + func execute( + draft: SecretDraft, + payload: Payload + ) async throws -> Secret + + func execute( + draft: SecretDraft, + payload: Payload, + metadata: Metadata + ) async throws -> Secret +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift new file mode 100644 index 0000000..b57c4f1 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift @@ -0,0 +1,9 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol DeleteSecretUseCase: Sendable { + func softDelete(id: UUID) async throws -> Secret + func restore(id: UUID) async throws -> Secret + func permanentlyDelete(id: UUID) async throws +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift new file mode 100644 index 0000000..eaf58d1 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift @@ -0,0 +1,12 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol FetchSecretUseCase: Sendable { + func fetch(id: UUID) async throws -> Secret? + func fetch(query: SecretQuery) async throws -> [Secret] + func revealPayload( + id: UUID, + as type: Payload.Type + ) async throws -> Payload +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift new file mode 100644 index 0000000..b4201f2 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift @@ -0,0 +1,16 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol PatchSecretUseCase: Sendable { + func patch(id: UUID, with patch: SecretPatch) async throws -> Secret + func updatePayload( + id: UUID, + payload: Payload + ) async throws -> Secret + func updateMetadata( + id: UUID, + metadata: Metadata + ) async throws -> Secret + func removeMetadata(id: UUID) async throws -> Secret +} diff --git a/Projects/DVDomain/Tests/.gitkeep b/Projects/DVDomain/Tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Projects/DVDomain/Tests/ExampleTest.swift b/Projects/DVDomain/Tests/ExampleTest.swift deleted file mode 100644 index 31145ca..0000000 --- a/Projects/DVDomain/Tests/ExampleTest.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Testing -@testable import DVDomain - -@Suite("Example") -struct ExampleTest { - @Test("조건을 만족해야 한다") - func example() { - #expect(1 + 1 == 2) - } -} diff --git a/Projects/DVPresentation/Project.swift b/Projects/DVPresentation/Project.swift index 1804fda..68afb41 100644 --- a/Projects/DVPresentation/Project.swift +++ b/Projects/DVPresentation/Project.swift @@ -8,7 +8,12 @@ let project = Project.project( name: DVModule.DVPresentation.name, product: Project.product, sources: .sources, - resources: .default + resources: .default, + dependencies: [ + .domain(), + .design(), + .core(), + ] ), ] ) diff --git a/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift b/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift new file mode 100644 index 0000000..2416a31 --- /dev/null +++ b/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift @@ -0,0 +1,231 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import SwiftUI + +public struct SecretUseCaseDemoView: View { + private let createSecretUseCase: any CreateSecretUseCase + private let fetchSecretUseCase: any FetchSecretUseCase + + @State private var selectedSecret: Secret? + @State private var revealedPayload: APIKeyPayload? + @State private var secrets: [Secret] = [] + @State private var statusMessage = "Ready" + @State private var isRunning = false + + public init( + createSecretUseCase: any CreateSecretUseCase, + fetchSecretUseCase: any FetchSecretUseCase + ) { + self.createSecretUseCase = createSecretUseCase + self.fetchSecretUseCase = fetchSecretUseCase + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + header + controls + status + content + } + .padding(24) + .frame(minWidth: 760, minHeight: 480) + .task { + await fetchAllSecrets() + } + } +} + +private extension SecretUseCaseDemoView { + var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Secret CRUD Demo") + .font(.title2) + .fontWeight(.semibold) + Text("UseCase 기반 생성, 목록 조회, 상세 조회, payload reveal 확인") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + var controls: some View { + HStack(spacing: 10) { + Button { + Task { await createDemoSecret() } + } label: { + Text(isRunning ? "Creating..." : "Demo Create") + } + .disabled(isRunning) + .keyboardShortcut(.defaultAction) + + Button("Refresh") { + Task { await fetchAllSecrets() } + } + .disabled(isRunning) + } + } + + var status: some View { + Text(statusMessage) + .font(.callout) + .foregroundStyle(isRunning ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + var content: some View { + HStack(alignment: .top, spacing: 20) { + list + .frame(minWidth: 260, idealWidth: 300, maxWidth: 340) + Divider() + detail + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + var list: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Secrets") + .font(.headline) + Spacer() + Text("\(secrets.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + + List(secrets, selection: selectedSecretBinding) { secret in + VStack(alignment: .leading, spacing: 5) { + Text(secret.name) + .font(.callout) + .fontWeight(.medium) + .lineLimit(1) + Text(secret.updatedAt.formatted(date: .numeric, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + .tag(secret.id) + } + } + } + + var detail: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("Detail") + .font(.headline) + Spacer() + Button("Reveal Payload") { + Task { await revealSelectedPayload() } + } + .disabled(isRunning || selectedSecret == nil) + } + + if let selectedSecret { + Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) { + resultRow("ID", selectedSecret.id.uuidString) + resultRow("Name", selectedSecret.name) + resultRow("Type", selectedSecret.secretType) + resultRow("Service", selectedSecret.service ?? "-") + resultRow("Environment", selectedSecret.environment ?? "-") + resultRow("Liked", selectedSecret.liked ? "true" : "false") + resultRow("Created", selectedSecret.createdAt.formatted(date: .numeric, time: .standard)) + resultRow("Updated", selectedSecret.updatedAt.formatted(date: .numeric, time: .standard)) + resultRow("Memo", selectedSecret.memo ?? "-") + resultRow("Encrypted bytes", "\(selectedSecret.payload.encryptedData.count)") + resultRow("Payload", revealedPayload?.value ?? "Hidden") + } + .font(.callout) + } else { + Text("Select a secret from the list.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + } + + var selectedSecretBinding: Binding { + Binding( + get: { selectedSecret?.id }, + set: { id in + selectedSecret = secrets.first { $0.id == id } + revealedPayload = nil + } + ) + } + + func resultRow(_ title: String, _ value: String) -> some View { + GridRow { + Text(title) + .foregroundStyle(.secondary) + .frame(width: 120, alignment: .leading) + Text(value) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + + func createDemoSecret() async { + await run("Creating secret...") { + let timestamp = Int(Date().timeIntervalSince1970) + let draft = SecretDraft( + name: "Demo Secret \(timestamp)", + secretType: "apiKey", + service: "github", + environment: "development", + memo: "Created from temporary Presentation UseCase demo", + liked: true + ) + let payload = APIKeyPayload(value: "demo-api-key-\(timestamp)") + let metadata = APIKeyMetadata(scope: "repo") + + let created = try await createSecretUseCase.execute( + draft: draft, + payload: payload, + metadata: metadata + ) + secrets = try await fetchSecretUseCase.fetch(query: SecretQuery()) + selectedSecret = try await fetchSecretUseCase.fetch(id: created.id) + revealedPayload = nil + statusMessage = "Created demo secret" + } + } + + func revealSelectedPayload() async { + guard let id = selectedSecret?.id else { return } + + await run("Revealing payload...") { + revealedPayload = try await fetchSecretUseCase.revealPayload(id: id, as: APIKeyPayload.self) + statusMessage = "Payload revealed" + } + } + + func fetchAllSecrets() async { + await run("Fetching secrets...") { + secrets = try await fetchSecretUseCase.fetch(query: SecretQuery()) + if let selectedID = selectedSecret?.id { + selectedSecret = secrets.first { $0.id == selectedID } + } else { + selectedSecret = secrets.first + } + revealedPayload = nil + statusMessage = "Fetched \(secrets.count) secret(s)" + } + } + + func run(_ message: String, operation: () async throws -> Void) async { + isRunning = true + statusMessage = message + + do { + try await operation() + } catch { + statusMessage = "Failed: \(error)" + } + + isRunning = false + } +} diff --git a/Projects/Devault/Project.swift b/Projects/Devault/Project.swift index 803e3b1..3feb11c 100644 --- a/Projects/Devault/Project.swift +++ b/Projects/Devault/Project.swift @@ -11,7 +11,13 @@ let project = Project.project( "CFBundleDisplayName": .string("Devault"), ]), sources: .sources, - resources: .default + resources: .default, + dependencies: [ + .presentation(), + .data(), + .domain(), + .core(), + ] ), ], schemes: [ diff --git a/Projects/Devault/Sources/ContentView.swift b/Projects/Devault/Sources/ContentView.swift index 2072de6..847e9e4 100644 --- a/Projects/Devault/Sources/ContentView.swift +++ b/Projects/Devault/Sources/ContentView.swift @@ -1,12 +1,26 @@ +import DVData +import DVDomain +import DVPresentation import SwiftUI struct ContentView: View { + private let repository = SecretRepositoryImpl( + modelContainer: LocalStorage.shared.modelContainer + ) + private let cryptoService = SecretCryptoServiceImpl() + private let authenticationService = LocalUserAuthenticationServiceImpl() + var body: some View { - VStack(spacing: 16) { - Text("Devault") - .font(.largeTitle) - .fontWeight(.bold) - } - .frame(width: 400, height: 300) + SecretUseCaseDemoView( + createSecretUseCase: CreateSecretUseCaseImpl( + repository: repository, + cryptoService: cryptoService + ), + fetchSecretUseCase: FetchSecretUseCaseImpl( + repository: repository, + cryptoService: cryptoService, + authenticationService: authenticationService + ) + ) } }