Refactor iOS app native logic to frameworks and make SwiftUI preview work (#771)

This commit is contained in:
Leendert de Borst
2025-04-27 20:57:04 +02:00
parent a86896ee99
commit 535de6b7b4
21 changed files with 1837 additions and 829 deletions

View File

@@ -50,9 +50,20 @@
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CE9A5BAF2DBBB47200CB0A4C"
BuildableName = "VaultStoreTests.xctest"
BlueprintName = "VaultStoreTests"
BlueprintIdentifier = "CEE480902DBE86DD00F4A367"
BuildableName = "VaultStoreKitTests.xctest"
BlueprintName = "VaultStoreKitTests"
ReferencedContainer = "container:AliasVault.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CEE481732DBE8AC800F4A367"
BuildableName = "VaultUITests.xctest"
BlueprintName = "VaultUITests"
ReferencedContainer = "container:AliasVault.xcodeproj">
</BuildableReference>
</TestableReference>

View File

@@ -1,4 +1,5 @@
import AuthenticationServices
import VaultModels
/**
* Native iOS implementation of the CredentialIdentityStore protocol.

View File

@@ -1,628 +0,0 @@
import SwiftUI
import AuthenticationServices
import Macaw
let placeholderImageBase64 = "UklGRjoEAABXRUJQVlA4IC4EAAAwFwCdASqAAIAAPpFCm0olo6Ihp5IraLASCWUA0eb/0s56RrLtCnYfLPiBshdXWMx8j1Ez65f169iA4xUDBTEV6ylMQeCIj2b7RngGi7gKZ9WjKdSoy9R8JcgOmjCMlDmLG20KhNo/i/Dc/Ah5GAvGfm8kfniV3AkR6fxN6eKwjDc6xrDgSfS48G5uGV6WzQt24YAVlLSK9BMwndzfHnePK1KFchFrL7O3ulB8cGNCeomu4o+l0SrS/JKblJ4WTzj0DAD++lCUEouSfgRKdiV2TiYCD+H+l3tANKSPQFPQuzi7rbvxqGeRmXB9kDwURaoSTTpYjA9REMUi9uA6aV7PWtBNXgUzMLowYMZeos6Xvyhb34GmufswMHA5ZyYpxzjTphOak4ZjNOiz8aScO5ygiTx99SqwX/uL+HSeVOSraHw8IymrMwm+jLxqN8BS8dGcItLlm/ioulqH2j4V8glDgSut+ExkxiD7m8TGPrrjCQNJbRDzpOFsyCyfBZupvp8QjGKW2KGziSZeIWes4aTB9tRmeEBhnUrmTDZQuXcc67Fg82KHrSfaeeOEq6jjuUjQ8wUnzM4Zz3dhrwSyslVz/WvnKqYkr4V/TTXPFF5EjF4rM1bHZ8bK63EfTnK41+n3n4gEFoYP4mXkNH0hntnYcdTqiE7Gn+q0BpRRxnkpBSZlA6Wa70jpW0FGqkw5e591A5/H+OV+60WAo+4Mi+NlsKrvLZ9EiVaPnoEFZlJQx1fA777AJ2MjXJ4KSsrWDWJi1lE8yPs8V6XvcC0chDTYt8456sKXAagCZyY+fzQriFMaddXyKQdG8qBqcdYjAsiIcjzaRFBBoOK9sU+sFY7N6B6+xtrlu3c37rQKkI3O2EoiJOris54EjJ5OFuumA0M6riNUuBf/MEPFBVx1JRcUEs+upEBsCnwYski7FT3TTqHrx7v5AjgFN97xhPTkmVpu6sxRnWBi1fxIRp8eWZeFM6mUcGgVk1WeVb1yhdV9hoMo2TsNEPE0tHo/wvuSJSzbZo7wibeXM9v/rRfKcx7X93rfiXVnyQ9f/5CaAQ4lxedPp/6uzLtOS4FyL0bCNeZ6L5w+AiuyWCTDFIYaUzhwfG+/YTQpWyeZCdQIKzhV+3GeXI2cxoP0ER/DlOKymf1gm+zRU3sqf1lBVQ0y+mK/Awl9bS3uaaQmI0FUyUwHUKP7PKuXnO+LcwDv4OfPT6hph8smc1EtMe5ib/apar/qZ9dyaEaElALJ1KKxnHziuvVl8atk1fINSQh7OtXDyqbPw9o/nGIpTnv5iFmwmWJLis2oyEgPkJqyx0vYI8rjkVEzKc8eQavAJBYSpjMwM193Swt+yJyjvaGYWPnqExxKiNarpB2WSO7soCAZXhS1uEYHryrK47BH6W1dRiruqT0xpLih3MXiwU3VDwAAAA=="
// Add Color extension for hex support
extension SwiftUI.Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default: (a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
struct ColorConstants {
struct Light {
static let text = SwiftUI.Color(hex: "#11181C")
static let textMuted = SwiftUI.Color(hex: "#4b5563")
static let background = SwiftUI.Color(hex: "#ffffff")
static let accentBackground = SwiftUI.Color(hex: "#fff")
static let accentBorder = SwiftUI.Color(hex: "#d1d5db")
static let primary = SwiftUI.Color(hex: "#f49541")
static let secondary = SwiftUI.Color(hex: "#6b7280")
static let icon = SwiftUI.Color(hex: "#687076")
}
struct Dark {
static let text = SwiftUI.Color(hex: "#ECEDEE")
static let textMuted = SwiftUI.Color(hex: "#9BA1A6")
static let background = SwiftUI.Color(hex: "#111827")
static let accentBackground = SwiftUI.Color(hex: "#1f2937")
static let accentBorder = SwiftUI.Color(hex: "#4b5563")
static let primary = SwiftUI.Color(hex: "#f49541")
static let secondary = SwiftUI.Color(hex: "#6b7280")
static let icon = SwiftUI.Color(hex: "#9BA1A6")
}
}
struct CredentialProviderView: View {
@ObservedObject var viewModel: CredentialProviderViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationView {
ZStack {
(colorScheme == .dark ? ColorConstants.Dark.background : ColorConstants.Light.background)
.ignoresSafeArea()
if viewModel.isLoading {
ProgressView("Loading credentials...")
.progressViewStyle(.circular)
.scaleEffect(1.5)
} else {
VStack(spacing: 0) {
SearchBar(text: $viewModel.searchText)
.padding(.horizontal)
.padding(.vertical, 8)
.background(colorScheme == .dark ? ColorConstants.Dark.background : ColorConstants.Light.background)
.onChange(of: viewModel.searchText) { _ in
viewModel.filterCredentials()
}
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.filteredCredentials, id: \.service) { credential in
CredentialCard(credential: credential) {
viewModel.selectCredential(credential)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
}
.refreshable {
viewModel.loadCredentials()
}
}
}
}
.navigationTitle("Select Credential")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
viewModel.cancel()
}
.foregroundColor(ColorConstants.Light.primary)
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button {
viewModel.loadCredentials()
} label: {
Image(systemName: "arrow.clockwise")
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.icon : ColorConstants.Light.icon)
}
Button("Add") {
viewModel.showAddCredential = true
}
.foregroundColor(ColorConstants.Light.primary)
}
}
}
.sheet(isPresented: $viewModel.showAddCredential) {
AddCredentialView(viewModel: viewModel)
}
.alert("Error", isPresented: $viewModel.showError) {
Button("OK") {
viewModel.cancel()
}
} message: {
Text(viewModel.errorMessage)
}
.task {
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.loadCredentials()
}
.onDisappear {
viewModel.cancel()
}
}
}
}
struct ServiceLogoView: View {
let logoData: Data?
@Environment(\.colorScheme) private var colorScheme
private var placeholderImage: UIImage? {
if let data = Data(base64Encoded: placeholderImageBase64) {
return UIImage(data: data)
}
return nil
}
private func detectMimeType(_ data: Data) -> String {
// Check for SVG
if let str = String(data: data.prefix(5), encoding: .utf8)?.lowercased(),
str.contains("<?xml") || str.contains("<svg") {
return "image/svg+xml"
}
// Check file signature for PNG
let bytes = [UInt8](data.prefix(4))
if bytes.count >= 4 &&
bytes[0] == 0x89 && bytes[1] == 0x50 &&
bytes[2] == 0x4E && bytes[3] == 0x47 {
return "image/png"
}
return "image/x-icon"
}
private func renderSVGNode(_ data: Data) -> Node? {
if let svgString = String(data: data, encoding: .utf8) {
return try? SVGParser.parse(text: svgString)
}
return nil
}
struct SVGImageView: UIViewRepresentable {
let node: Node
func makeUIView(context: Context) -> MacawView {
let macawView = MacawView(node: node, frame: CGRect(x: 0, y: 0, width: 32, height: 32))
macawView.backgroundColor = .clear
macawView.contentMode = .scaleAspectFit
macawView.node.place = Transform.identity
return macawView
}
func updateUIView(_ uiView: MacawView, context: Context) {
uiView.node = node
uiView.backgroundColor = .clear
uiView.contentMode = .scaleAspectFit
uiView.node.place = Transform.identity
}
}
var body: some View {
Group {
if let logoData = logoData {
let mimeType = detectMimeType(logoData)
if mimeType == "image/svg+xml",
let svgNode = renderSVGNode(logoData) {
SVGImageView(node: svgNode)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else if let image = UIImage(data: logoData) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else if let placeholder = placeholderImage {
Image(uiImage: placeholder)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius:4))
}
} else if let placeholder = placeholderImage {
Image(uiImage: placeholder)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else {
// Ultimate fallback if placeholder fails to load
Circle()
.fill(colorScheme == .dark ? ColorConstants.Dark.accentBackground : ColorConstants.Light.background)
.frame(width: 32, height: 32)
.overlay(
Circle()
.stroke(colorScheme == .dark ? ColorConstants.Dark.accentBorder : ColorConstants.Light.accentBorder, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
}
}
struct CredentialCard: View {
let credential: Credential
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
// Service logo
ServiceLogoView(logoData: credential.service.logo)
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 4) {
Text(credential.service.name ?? "Unknown Service")
.font(.headline)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
Text(credential.username ?? "No username")
.font(.subheadline)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.icon : ColorConstants.Light.icon)
}
.padding(12)
.background(colorScheme == .dark ? ColorConstants.Dark.accentBackground : ColorConstants.Light.background)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(colorScheme == .dark ? ColorConstants.Dark.accentBorder : ColorConstants.Light.accentBorder, lineWidth: 1)
)
.cornerRadius(8)
}
}
}
struct AddCredentialView: View {
@ObservedObject var viewModel: CredentialProviderViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
Form {
TextField("Username", text: $viewModel.newUsername)
.textContentType(.username)
.autocapitalization(.none)
SecureField("Password", text: $viewModel.newPassword)
.textContentType(.password)
TextField("Service", text: $viewModel.newService)
.textContentType(.URL)
.autocapitalization(.none)
}
.navigationTitle("Add Credential")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
viewModel.addCredential()
dismiss()
}
.disabled(viewModel.newUsername.isEmpty ||
viewModel.newPassword.isEmpty ||
viewModel.newService.isEmpty)
}
}
}
}
}
struct SearchBar: View {
@Binding var text: String
var body: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
TextField("Search credentials...", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
if !text.isEmpty {
Button(action: {
text = ""
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
}
}
}
}
class CredentialProviderViewModel: ObservableObject {
@Published var credentials: [Credential] = []
@Published var filteredCredentials: [Credential] = []
@Published var searchText = ""
@Published var isLoading = true
@Published var showError = false
@Published var errorMessage = ""
@Published var showAddCredential = false
// New credential form fields
@Published var newUsername = ""
@Published var newPassword = ""
@Published var newService = ""
private var extensionContext: ASCredentialProviderExtensionContext?
init(extensionContext: ASCredentialProviderExtensionContext? = nil) {
self.extensionContext = extensionContext
}
func loadCredentials() {
isLoading = true
do {
let vaultStore = VaultStore()
// Initialize the DB. Note: this can prompt the user for biometric authentication.
try vaultStore.initializeDatabase()
credentials = try vaultStore.getAllCredentials()
filteredCredentials = credentials
Task {
do {
try await CredentialIdentityStore.shared.saveCredentialIdentities(credentials)
DispatchQueue.main.async { [weak self] in
self?.isLoading = false
}
} catch {
await handleError(error)
}
}
} catch {
handleError(error)
}
}
func filterCredentials() {
if searchText.isEmpty {
filteredCredentials = credentials
} else {
filteredCredentials = credentials.filter { credential in
(credential.service.name?.localizedCaseInsensitiveContains(searchText) ?? false) ||
(credential.username?.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
}
func selectCredential(_ credential: Credential) {
guard let username = credential.username else {
handleError(NSError(domain: "CredentialProvider", code: -1, userInfo: [NSLocalizedDescriptionKey: "Username is required"]))
return
}
// Note: We need to get the password from the Password model
// This will need to be updated once we have access to the Password model
let passwordCredential = ASPasswordCredential(user: username, password: credential.password?.value ?? "")
extensionContext?.completeRequest(withSelectedCredential: passwordCredential,
completionHandler: nil)
}
func addCredential() {
// Note: This will need to be updated to create proper Service and Password models
// For now, we'll just create a basic credential
let service = Service(
id: UUID(),
name: newService,
url: nil,
logo: nil,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
let password = Password(
id: UUID(),
credentialId: UUID(),
value: newPassword,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
let alias = Alias(
id: UUID(),
gender: nil,
firstName: nil,
lastName: nil,
nickName: nil,
birthDate: Date(),
email: nil,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
let credential = Credential(
id: UUID(),
alias: alias,
service: service,
username: newUsername,
notes: nil,
password: password,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
do {
let vaultStore = VaultStore()
try vaultStore.addCredential(credential)
Task {
try await CredentialIdentityStore.shared.saveCredentialIdentities([credential])
}
loadCredentials()
// Reset form
newUsername = ""
newPassword = ""
newService = ""
} catch {
handleError(error)
}
}
func cancel() {
guard let context = extensionContext else {
print("Error: extensionContext is nil")
return
}
context.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain,
code: ASExtensionError.userCanceled.rawValue))
}
private func handleError(_ error: Error) {
DispatchQueue.main.async { [weak self] in
self?.isLoading = false
self?.errorMessage = error.localizedDescription
self?.showError = true
}
}
}
// MARK: - Preview Helpers
extension Service {
static var preview: Service {
Service(
id: UUID(),
name: "Example Service",
url: "https://example.com",
logo: Data(base64Encoded: placeholderImageBase64),
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
}
}
extension Password {
static var preview: Password {
Password(
id: UUID(),
credentialId: UUID(),
value: "password123",
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
}
}
extension Alias {
static var preview: Alias {
Alias(
id: UUID(),
gender: "Not specified",
firstName: "John",
lastName: "Doe",
nickName: "JD",
birthDate: Date(),
email: "john@example.com",
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
}
}
extension Credential {
static var preview: Credential {
Credential(
id: UUID(),
alias: .preview,
service: .preview,
username: "johndoe",
notes: "Sample credential",
password: .preview,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
}
}
class PreviewCredentialProviderViewModel: CredentialProviderViewModel {
override init(extensionContext: ASCredentialProviderExtensionContext? = nil) {
super.init(extensionContext: nil)
self.credentials = [
.preview,
Credential(
id: UUID(),
alias: .preview,
service: Service(
id: UUID(),
name: "Another Service",
url: "https://another.com",
logo: nil,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
),
username: "anotheruser",
notes: "Another sample credential",
password: .preview,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
]
self.filteredCredentials = self.credentials
self.isLoading = false
}
}
struct CredentialProviderView_Previews: PreviewProvider {
static var previews: some View {
Group {
// Light mode preview
CredentialProviderView(viewModel: PreviewCredentialProviderViewModel())
.preferredColorScheme(.light)
.previewDisplayName("Light Mode")
// Dark mode preview
CredentialProviderView(viewModel: PreviewCredentialProviderViewModel())
.preferredColorScheme(.dark)
.previewDisplayName("Dark Mode")
// Loading state preview
CredentialProviderView(viewModel: {
let vm = PreviewCredentialProviderViewModel()
vm.isLoading = true
return vm
}())
.previewDisplayName("Loading State")
// Empty state preview
CredentialProviderView(viewModel: {
let vm = PreviewCredentialProviderViewModel()
vm.credentials = []
vm.filteredCredentials = []
return vm
}())
.previewDisplayName("Empty State")
// Search state preview
CredentialProviderView(viewModel: {
let vm = PreviewCredentialProviderViewModel()
vm.searchText = "john"
vm.filterCredentials()
return vm
}())
.previewDisplayName("Search State")
}
}
}

View File

@@ -7,6 +7,9 @@
import AuthenticationServices
import SwiftUI
import VaultStoreKit
import VaultUI
import VaultModels
/**
* This class is the main entry point for the autofill extension.
@@ -20,11 +23,33 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
override func viewDidLoad() {
super.viewDidLoad()
let viewModel = CredentialProviderViewModel(extensionContext: extensionContext)
let hostingController = UIHostingController(
rootView: CredentialProviderView(viewModel: viewModel)
// Create the ViewModel with INJECTED behaviors
let viewModel = CredentialProviderViewModel(
loader: {
try VaultStore.shared.initializeDatabase()
return try VaultStore.shared.getAllCredentials()
},
selectionHandler: { [weak self] credential in
guard let self = self else { return }
let passwordCredential = ASPasswordCredential(
user: credential.username ?? "",
password: credential.password?.value ?? ""
)
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
},
cancelHandler: { [weak self] in
guard let self = self else { return }
self.extensionContext.cancelRequest(withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.userCanceled.rawValue
))
}
)
let hostingController = UIHostingController(
rootView: CredentialProviderView(viewModel: viewModel)
)
addChild(hostingController)
view.addSubview(hostingController.view)

View File

@@ -1,6 +1,8 @@
import Foundation
import SQLite
import LocalAuthentication
import VaultStoreKit
import VaultModels
/**
* This class is used as a bridge to allow React Native to interact with the VaultStore class.

View File

@@ -17,6 +17,7 @@ target 'AliasVault' do
use_expo_modules!
pod 'KeychainAccess', '~> 4.2.2'
pod 'SQLite.swift', '~> 0.14.0'
pod 'Macaw', '~> 0.9.7'
pod 'ExpoModulesCore', :path => '../node_modules/expo-modules-core'
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
@@ -55,6 +56,13 @@ target 'AliasVault' do
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
)
# Make sure preview works for certain imports like "Macaw", without this SwiftUI preview will fail
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'NO'
end
end
# This is necessary for Xcode 14, because it signs resource bundles by default
# when building for devices.
installer.target_installation_results.pod_target_installation_results
@@ -74,7 +82,20 @@ target 'Autofill' do
pod 'Macaw', '~> 0.9.7'
end
target 'VaultStoreTests' do
target 'VaultStoreKit' do
pod 'KeychainAccess', '~> 4.2.2'
pod 'SQLite.swift', '~> 0.14.0'
end
end
target 'VaultStoreKitTests' do
pod 'KeychainAccess', '~> 4.2.2'
pod 'SQLite.swift', '~> 0.14.0'
end
target 'VaultUI' do
pod 'Macaw', '~> 0.9.7'
end
target 'VaultUITests' do
pod 'Macaw', '~> 0.9.7'
end

View File

@@ -2608,6 +2608,6 @@ SPEC CHECKSUMS:
SWXMLHash: dd733a457e9c4fe93b1538654057aefae4acb382
Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a
PODFILE CHECKSUM: 667ccd24947933a6749a53afdd8b0323040c64b6
PODFILE CHECKSUM: dd7ef23f0a6751de51c8ab4ac01642ab1091f40e
COCOAPODS: 1.16.2

View File

@@ -1,6 +1,6 @@
# VaultStore
# ``VaultStoreKit``
The VaultStore is the core native iOS module for AliasVault that handles all critical data operations. This module serves as the deepest level of data management in the iOS app, providing secure and efficient access to sensitive information.
The VaultStoreKit is the core native iOS module for AliasVault that handles all critical data operations. This module serves as the deepest level of data management in the iOS app, providing secure and efficient access to sensitive information.
## Key Components
@@ -19,4 +19,3 @@ The VaultStore is accessed by the React Native layer through Turbo Modules, whic
## Integration
The module is designed to be accessed exclusively through the Turbo Module interface, ensuring proper encapsulation of sensitive operations while maintaining the benefits of cross-platform development.

View File

@@ -4,6 +4,7 @@ import SQLite
import LocalAuthentication
import CryptoKit
import CommonCrypto
import VaultModels
/**
* This class is used to store and retrieve the encrypted AliasVault database and encryption key.
@@ -90,7 +91,7 @@ public class VaultStore {
}
// MARK: - Auth Methods Management
func setAuthMethods(_ methods: AuthMethods) throws {
public func setAuthMethods(_ methods: AuthMethods) throws {
enabledAuthMethods = methods
UserDefaults.standard.set(methods.rawValue, forKey: authMethodsKey)
UserDefaults.standard.synchronize()
@@ -112,11 +113,11 @@ public class VaultStore {
}
}
func getAuthMethods() -> AuthMethods {
public func getAuthMethods() -> AuthMethods {
return enabledAuthMethods
}
func getAuthMethodsAsStrings() -> [String] {
public func getAuthMethodsAsStrings() -> [String] {
var methods: [String] = []
if enabledAuthMethods.contains(.faceID) {
methods.append("faceid")
@@ -128,14 +129,14 @@ public class VaultStore {
}
// MARK: - Vault Status
func isVaultInitialized() -> Bool {
public func isVaultInitialized() -> Bool {
// Check if encrypted database file exists
let hasDatabase = FileManager.default.fileExists(atPath: getEncryptedDbPath().path)
return hasDatabase
}
func isVaultUnlocked() -> Bool {
public func isVaultUnlocked() -> Bool {
// Check if encryption key is in memory
return encryptionKey != nil
}
@@ -189,7 +190,7 @@ public class VaultStore {
throw NSError(domain: "VaultStore", code: 3, userInfo: [NSLocalizedDescriptionKey: "No encryption key found in memory"])
}
func storeEncryptionKey(base64Key: String) throws {
public func storeEncryptionKey(base64Key: String) throws {
// Convert base64 string to bytes
guard let keyData = Data(base64Encoded: base64Key) else {
throw NSError(domain: "VaultStore", code: 6, userInfo: [NSLocalizedDescriptionKey: "Invalid base64 key"])
@@ -237,7 +238,7 @@ public class VaultStore {
// and metadata in UserDefaults
// TODO: refactor metadata save and retrieve calls to separate calls for better clarity throughtout
// full call chain?
func storeEncryptedDatabase(_ base64EncryptedDb: String, metadata: String) throws {
public func storeEncryptedDatabase(_ base64EncryptedDb: String, metadata: String) throws {
// Store the encrypted database (base64 encoded) in the app's documents directory
try base64EncryptedDb.write(to: getEncryptedDbPath(), atomically: true, encoding: .utf8)
@@ -269,7 +270,7 @@ public class VaultStore {
*
* The in-memory database is used for all queries and updates to the database.
*/
func initializeDatabase() throws {
public func initializeDatabase() throws {
// Get the encrypted database
guard let encryptedDbBase64 = getEncryptedDatabase() else {
throw NSError(domain: "VaultStore", code: 1, userInfo: [NSLocalizedDescriptionKey: "No encrypted database found"])
@@ -359,7 +360,7 @@ public class VaultStore {
))
}
func getAllCredentials() throws -> [Credential] {
public func getAllCredentials() throws -> [Credential] {
guard let db = db else {
throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"])
}
@@ -527,7 +528,7 @@ public class VaultStore {
}
// Clears cached encryption key and encrypted database to force re-initialization on next access.
func clearCache() {
public func clearCache() {
print("Clearing cache - removing encryption key and decrypted database from memory")
// Clear the cached encryption key
@@ -538,7 +539,7 @@ public class VaultStore {
}
// Clears cached and saved encryption key and encrypted database to force re-initialization on next access.
func clearVault() {
public func clearVault() {
print("Clearing vault - removing all stored data")
// Remove the encryption key from keychain with proper error handling
@@ -571,7 +572,7 @@ public class VaultStore {
// MARK: - Query Execution
func executeQuery(_ query: String, params: [Binding?]) throws -> [[String: Any]] {
public func executeQuery(_ query: String, params: [Binding?]) throws -> [[String: Any]] {
guard let db = db else {
throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"])
}
@@ -609,7 +610,7 @@ public class VaultStore {
return results
}
func executeUpdate(_ query: String, params: [Binding?]) throws -> Int {
public func executeUpdate(_ query: String, params: [Binding?]) throws -> Int {
guard let db = db else {
throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"])
}
@@ -620,14 +621,14 @@ public class VaultStore {
}
// MARK: - Auto Lock Timeout Management
func setAutoLockTimeout(_ timeout: Int) {
public func setAutoLockTimeout(_ timeout: Int) {
print("Setting auto-lock timeout to \(timeout) seconds")
autoLockTimeout = timeout
UserDefaults.standard.set(timeout, forKey: autoLockTimeoutKey)
UserDefaults.standard.synchronize()
}
func getAutoLockTimeout() -> Int {
public func getAutoLockTimeout() -> Int {
return autoLockTimeout
}

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,14 @@
import XCTest
//
// VaultStoreKitTests.swift
// VaultStoreKitTests
//
// Created by Leendert de Borst on 27/04/2025.
//
class VaultStoreTests: XCTestCase {
import XCTest
@testable import VaultStoreKit
final class VaultStoreKitTests: XCTestCase {
var vaultStore: VaultStore!
let testEncryptionKeyBase64 = "/9So3C83JLDIfjsF0VQOc4rz1uAFtIseW7yrUuztAD0=" // 32 bytes for AES-256
@@ -102,7 +110,7 @@ class VaultStoreTests: XCTestCase {
guard let testDbPath = Bundle(for: type(of: self))
.path(forResource: "test-encrypted-vault", ofType: "txt")
else {
throw NSError(domain: "VaultStoreTests",
throw NSError(domain: "VaultStoreKitTests",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Test database file not found"])
}

View File

@@ -0,0 +1,48 @@
import SwiftUI
public struct ColorConstants {
public struct Light {
static let text = SwiftUI.Color(hex: "#11181C")
static let textMuted = SwiftUI.Color(hex: "#4b5563")
static let background = SwiftUI.Color(hex: "#ffffff")
static let accentBackground = SwiftUI.Color(hex: "#fff")
static let accentBorder = SwiftUI.Color(hex: "#d1d5db")
static let primary = SwiftUI.Color(hex: "#f49541")
static let secondary = SwiftUI.Color(hex: "#6b7280")
static let icon = SwiftUI.Color(hex: "#687076")
}
public struct Dark {
static let text = SwiftUI.Color(hex: "#ECEDEE")
static let textMuted = SwiftUI.Color(hex: "#9BA1A6")
static let background = SwiftUI.Color(hex: "#111827")
static let accentBackground = SwiftUI.Color(hex: "#1f2937")
static let accentBorder = SwiftUI.Color(hex: "#4b5563")
static let primary = SwiftUI.Color(hex: "#f49541")
static let secondary = SwiftUI.Color(hex: "#6b7280")
static let icon = SwiftUI.Color(hex: "#9BA1A6")
}
}
// Add Color extension for hex support
extension SwiftUI.Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default: (a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View File

@@ -0,0 +1,93 @@
//
// CredentialCardView.swift
// AliasVault
//
// Created by Leendert de Borst on 27/04/2025.
//
import SwiftUI
import VaultModels
struct CredentialCard: View {
let credential: Credential
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
// Service logo
ServiceLogoView(logoData: credential.service.logo)
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 4) {
Text(credential.service.name ?? "Unknown Service")
.font(.headline)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
Text(credential.username ?? "No username")
.font(.subheadline)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.icon : ColorConstants.Light.icon)
}
.padding(8)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(colorScheme == .dark ? ColorConstants.Dark.accentBackground : ColorConstants.Light.background)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(colorScheme == .dark ? ColorConstants.Dark.accentBorder : ColorConstants.Light.accentBorder, lineWidth: 1)
)
.cornerRadius(8)
}
}
}
#Preview {
CredentialCard(
credential: Credential(
id: UUID(),
alias: Alias(
id: UUID(),
gender: "Male",
firstName: "John",
lastName: "Doe",
nickName: "Johnny",
birthDate: Date(),
email: "john.doe@example.com",
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
),
service: Service(
id: UUID(),
name: "Example Service",
url: "https://example.com",
logo: nil,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
),
username: "johndoe",
notes: "Sample notes",
password: Password(
id: UUID(),
credentialId: UUID(),
value: "securepassword123",
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
),
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
),
action: {}
)
}

View File

@@ -0,0 +1,113 @@
//
// ServiceLogoView.swift
// VaultUI
//
// Created by Leendert de Borst on 27/04/2025.
//
import SwiftUI
import Macaw
struct ServiceLogoView: View {
private let placeholderImageBase64 = "UklGRjoEAABXRUJQVlA4IC4EAAAwFwCdASqAAIAAPpFCm0olo6Ihp5IraLASCWUA0eb/0s56RrLtCnYfLPiBshdXWMx8j1Ez65f169iA4xUDBTEV6ylMQeCIj2b7RngGi7gKZ9WjKdSoy9R8JcgOmjCMlDmLG20KhNo/i/Dc/Ah5GAvGfm8kfniV3AkR6fxN6eKwjDc6xrDgSfS48G5uGV6WzQt24YAVlLSK9BMwndzfHnePK1KFchFrL7O3ulB8cGNCeomu4o+l0SrS/JKblJ4WTzj0DAD++lCUEouSfgRKdiV2TiYCD+H+l3tANKSPQFPQuzi7rbvxqGeRmXB9kDwURaoSTTpYjA9REMUi9uA6aV7PWtBNXgUzMLowYMZeos6Xvyhb34GmufswMHA5ZyYpxzjTphOak4ZjNOiz8aScO5ygiTx99SqwX/uL+HSeVOSraHw8IymrMwm+jLxqN8BS8dGcItLlm/ioulqH2j4V8glDgSut+ExkxiD7m8TGPrrjCQNJbRDzpOFsyCyfBZupvp8QjGKW2KGziSZeIWes4aTB9tRmeEBhnUrmTDZQuXcc67Fg82KHrSfaeeOEq6jjuUjQ8wUnzM4Zz3dhrwSyslVz/WvnKqYkr4V/TTXPFF5EjF4rM1bHZ8bK63EfTnK41+n3n4gEFoYP4mXkNH0hntnYcdTqiE7Gn+q0BpRRxnkpBSZlA6Wa70jpW0FGqkw5e591A5/H+OV+60WAo+4Mi+NlsKrvLZ9EiVaPnoEFZlJQx1fA777AJ2MjXJ4KSsrWDWJi1lE8yPs8V6XvcC0chDTYt8456sKXAagCZyY+fzQriFMaddXyKQdG8qBqcdYjAsiIcjzaRFBBoOK9sU+sFY7N6B6+xtrlu3c37rQKkI3O2EoiJOris54EjJ5OFuumA0M6riNUuBf/MEPFBVx1JRcUEs+upEBsCnwYski7FT3TTqHrx7v5AjgFN97xhPTkmVpu6sxRnWBi1fxIRp8eWZeFM6mUcGgVk1WeVb1yhdV9hoMo2TsNEPE0tHo/wvuSJSzbZo7wibeXM9v/rRfKcx7X93rfiXVnyQ9f/5CaAQ4lxedPp/6uzLtOS4FyL0bCNeZ6L5w+AiuyWCTDFIYaUzhwfG+/YTQpWyeZCdQIKzhV+3GeXI2cxoP0ER/DlOKymf1gm+zRU3sqf1lBVQ0y+mK/Awl9bS3uaaQmI0FUyUwHUKP7PKuXnO+LcwDv4OfPT6hph8smc1EtMe5ib/apar/qZ9dyaEaElALJ1KKxnHziuvVl8atk1fINSQh7OtXDyqbPw9o/nGIpTnv5iFmwmWJLis2oyEgPkJqyx0vYI8rjkVEzKc8eQavAJBYSpjMwM193Swt+yJyjvaGYWPnqExxKiNarpB2WSO7soCAZXhS1uEYHryrK47BH6W1dRiruqT0xpLih3MXiwU3VDwAAAA=="
let logoData: Data?
@Environment(\.colorScheme) private var colorScheme
private var placeholderImage: UIImage? {
if let data = Data(base64Encoded: placeholderImageBase64) {
return UIImage(data: data)
}
return nil
}
private func detectMimeType(_ data: Data) -> String {
// Check for SVG
if let str = String(data: data.prefix(5), encoding: .utf8)?.lowercased(),
str.contains("<?xml") || str.contains("<svg") {
return "image/svg+xml"
}
// Check file signature for PNG
let bytes = [UInt8](data.prefix(4))
if bytes.count >= 4 &&
bytes[0] == 0x89 && bytes[1] == 0x50 &&
bytes[2] == 0x4E && bytes[3] == 0x47 {
return "image/png"
}
return "image/x-icon"
}
private func renderSVGNode(_ data: Data) -> Node? {
if let svgString = String(data: data, encoding: .utf8) {
return try? SVGParser.parse(text: svgString)
}
return nil
}
struct SVGImageView: UIViewRepresentable {
let node: Node
func makeUIView(context: Context) -> MacawView {
let macawView = MacawView(node: node, frame: CGRect(x: 0, y: 0, width: 32, height: 32))
macawView.backgroundColor = .clear
macawView.contentMode = .scaleAspectFit
macawView.node.place = Transform.identity
return macawView
}
func updateUIView(_ uiView: MacawView, context: Context) {
uiView.node = node
uiView.backgroundColor = .clear
uiView.contentMode = .scaleAspectFit
uiView.node.place = Transform.identity
}
}
var body: some View {
Group {
if let logoData = logoData {
let mimeType = detectMimeType(logoData)
if mimeType == "image/svg+xml",
let svgNode = renderSVGNode(logoData) {
SVGImageView(node: svgNode)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else if let image = UIImage(data: logoData) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else if let placeholder = placeholderImage {
Image(uiImage: placeholder)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius:4))
}
} else if let placeholder = placeholderImage {
Image(uiImage: placeholder)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else {
// Ultimate fallback if placeholder fails to load
Circle()
.fill(colorScheme == .dark ? ColorConstants.Dark.accentBackground : ColorConstants.Light.background)
.frame(width: 32, height: 32)
.overlay(
Circle()
.stroke(colorScheme == .dark ? ColorConstants.Dark.accentBorder : ColorConstants.Light.accentBorder, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
}
}
#Preview {
ServiceLogoView(logoData: nil)
}

View File

@@ -0,0 +1,13 @@
# ``VaultUI``
<!--@START_MENU_TOKEN@-->Summary<!--@END_MENU_TOKEN@-->
## Overview
<!--@START_MENU_TOKEN@-->Text<!--@END_MENU_TOKEN@-->
## Topics
### <!--@START_MENU_TOKEN@-->Group<!--@END_MENU_TOKEN@-->
- <!--@START_MENU_TOKEN@-->``Symbol``<!--@END_MENU_TOKEN@-->

View File

@@ -0,0 +1,355 @@
import SwiftUI
import AuthenticationServices
import VaultModels
public struct CredentialProviderView: View {
@ObservedObject public var viewModel: CredentialProviderViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
public init(viewModel: CredentialProviderViewModel) {
self._viewModel = ObservedObject(wrappedValue: viewModel)
}
public var body: some View {
NavigationView {
ZStack {
(colorScheme == .dark ? ColorConstants.Dark.background : ColorConstants.Light.background)
.ignoresSafeArea()
if viewModel.isLoading {
ProgressView("Loading credentials...")
.progressViewStyle(.circular)
.scaleEffect(1.5)
} else {
VStack(spacing: 0) {
SearchBar(text: $viewModel.searchText)
.padding(.horizontal)
.padding(.vertical, 8)
.background(colorScheme == .dark ? ColorConstants.Dark.background : ColorConstants.Light.background)
.onChange(of: viewModel.searchText) { _ in
viewModel.filterCredentials()
}
ScrollView {
LazyVStack(spacing: 8) {
ForEach(viewModel.filteredCredentials, id: \.service) { credential in
CredentialCard(credential: credential) {
viewModel.selectCredential(credential)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
}
.refreshable {
await viewModel.loadCredentials()
}
}
}
}
.navigationTitle("Select Credential")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
viewModel.cancel()
}
.foregroundColor(ColorConstants.Light.primary)
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button {
Task { await viewModel.loadCredentials() }
} label: {
Image(systemName: "arrow.clockwise")
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.icon : ColorConstants.Light.icon)
}
Button("Add") {
viewModel.showAddCredential = true
}
.foregroundColor(ColorConstants.Light.primary)
}
}
}
.sheet(isPresented: $viewModel.showAddCredential) {
AddCredentialView(viewModel: viewModel)
}
.alert("Error", isPresented: $viewModel.showError) {
Button("OK") {
viewModel.dismissError()
}
} message: {
Text(viewModel.errorMessage)
}
.task {
try? await Task.sleep(nanoseconds: 100_000_000)
await viewModel.loadCredentials()
}
.onDisappear {
viewModel.cancel()
}
}
}
}
// MARK: - ViewModel
public class CredentialProviderViewModel: ObservableObject {
@Published var credentials: [Credential] = []
@Published var filteredCredentials: [Credential] = []
@Published var searchText = ""
@Published var isLoading = true
@Published var showError = false
@Published var errorMessage = ""
@Published var showAddCredential = false
@Published var newUsername = ""
@Published var newPassword = ""
@Published var newService = ""
private let loader: () async throws -> [Credential]
private let selectionHandler: (Credential) -> Void
private let cancelHandler: () -> Void
public init(
loader: @escaping () async throws -> [Credential],
selectionHandler: @escaping (Credential) -> Void,
cancelHandler: @escaping () -> Void
) {
self.loader = loader
self.selectionHandler = selectionHandler
self.cancelHandler = cancelHandler
}
@MainActor
func loadCredentials() async {
isLoading = true
do {
credentials = try await loader()
filterCredentials()
isLoading = false
} catch {
handleError(error)
}
}
func filterCredentials() {
if searchText.isEmpty {
filteredCredentials = credentials
} else {
filteredCredentials = credentials.filter { credential in
(credential.service.name?.localizedCaseInsensitiveContains(searchText) ?? false) ||
(credential.username?.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
}
func selectCredential(_ credential: Credential) {
selectionHandler(credential)
}
func cancel() {
cancelHandler()
}
func dismissError() {
showError = false
}
private func handleError(_ error: Error) {
DispatchQueue.main.async { [weak self] in
self?.isLoading = false
self?.errorMessage = error.localizedDescription
self?.showError = true
}
}
}
// MARK: - AddCredentialView
struct AddCredentialView: View {
@ObservedObject var viewModel: CredentialProviderViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
Form {
TextField("Username", text: $viewModel.newUsername)
.textContentType(.username)
.autocapitalization(.none)
SecureField("Password", text: $viewModel.newPassword)
.textContentType(.password)
TextField("Service", text: $viewModel.newService)
.textContentType(.URL)
.autocapitalization(.none)
}
.navigationTitle("Add Credential")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
//viewModel.addCredential()
dismiss()
}
.disabled(viewModel.newUsername.isEmpty ||
viewModel.newPassword.isEmpty ||
viewModel.newService.isEmpty)
}
}
}
}
}
// MARK: - SearchBar
struct SearchBar: View {
@Binding var text: String
var body: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
TextField("Search credentials...", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
if !text.isEmpty {
Button(action: {
text = ""
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
}
}
}
}
// MARK: - Preview Helpers
extension Service {
static var preview: Service {
Service(
id: UUID(),
name: "Example Service",
url: "https://example.com",
logo: nil,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
}
}
extension Password {
static var preview: Password {
Password(
id: UUID(),
credentialId: UUID(),
value: "password123",
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
}
}
extension Alias {
static var preview: Alias {
Alias(
id: UUID(),
gender: "Not specified",
firstName: "John",
lastName: "Doe",
nickName: "JD",
birthDate: Date(),
email: "john@example.com",
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
}
}
extension Credential {
static var preview: Credential {
Credential(
id: UUID(),
alias: .preview,
service: .preview,
username: "johndoe",
notes: "Sample credential",
password: .preview,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
}
}
// Preview setup
class PreviewCredentialProviderViewModel: CredentialProviderViewModel {
@Published var showSelectionAlert = false
@Published var selectedCredentialInfo = ""
@Published var showCancelAlert = false
init() {
let previewCredentials = [
.preview,
Credential(
id: UUID(),
alias: .preview,
service: Service(
id: UUID(),
name: "Another Service",
url: "https://another.com",
logo: nil,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
),
username: "anotheruser",
notes: "Another sample credential",
password: .preview,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
]
super.init(
loader: {
try? await Task.sleep(nanoseconds: 1_000_000_000) // Simulate network delay
return previewCredentials
},
selectionHandler: { credential in
print("Selected credential: \(credential)")
},
cancelHandler: {
print("Canceled")
}
)
self.credentials = previewCredentials
self.filteredCredentials = previewCredentials
self.isLoading = false
}
}
struct CredentialProviderView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = PreviewCredentialProviderViewModel()
CredentialProviderView(viewModel: viewModel)
}
}

View File

@@ -0,0 +1,36 @@
//
// VaultUITests.swift
// VaultUITests
//
// Created by Leendert de Borst on 27/04/2025.
//
import XCTest
@testable import VaultUI
final class VaultUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
// Any test you write for XCTest can be annotated as throws and async.
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -29,12 +29,13 @@
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "AliasVaultTests.xctest"
BlueprintName = "AliasVaultTests"
BlueprintIdentifier = "CEE480902DBE86DD00F4A367"
BuildableName = "VaultStoreKitTests.xctest"
BlueprintName = "VaultStoreKitTests"
ReferencedContainer = "container:AliasVault.xcodeproj">
</BuildableReference>
</TestableReference>
@@ -43,9 +44,9 @@
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CE9A5BAF2DBBB47200CB0A4C"
BuildableName = "VaultStoreTests.xctest"
BlueprintName = "VaultStoreTests"
BlueprintIdentifier = "CEE481732DBE8AC800F4A367"
BuildableName = "VaultUITests.xctest"
BlueprintName = "VaultUITests"
ReferencedContainer = "container:AliasVault.xcodeproj">
</BuildableReference>
</TestableReference>