mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-16 12:29:01 -04:00
Refactor iOS app native logic to frameworks and make SwiftUI preview work (#771)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AuthenticationServices
|
||||
import VaultModels
|
||||
|
||||
/**
|
||||
* Native iOS implementation of the CredentialIdentityStore protocol.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2608,6 +2608,6 @@ SPEC CHECKSUMS:
|
||||
SWXMLHash: dd733a457e9c4fe93b1538654057aefae4acb382
|
||||
Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a
|
||||
|
||||
PODFILE CHECKSUM: 667ccd24947933a6749a53afdd8b0323040c64b6
|
||||
PODFILE CHECKSUM: dd7ef23f0a6751de51c8ab4ac01642ab1091f40e
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"])
|
||||
}
|
||||
48
mobile-app/ios/VaultUI/ColorConstants.swift
Normal file
48
mobile-app/ios/VaultUI/ColorConstants.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
93
mobile-app/ios/VaultUI/Components/CredentialCardView.swift
Normal file
93
mobile-app/ios/VaultUI/Components/CredentialCardView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
|
||||
113
mobile-app/ios/VaultUI/Components/ServiceLogoView.swift
Normal file
113
mobile-app/ios/VaultUI/Components/ServiceLogoView.swift
Normal 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)
|
||||
}
|
||||
13
mobile-app/ios/VaultUI/VaultUI.docc/VaultUI.md
Normal file
13
mobile-app/ios/VaultUI/VaultUI.docc/VaultUI.md
Normal 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@-->
|
||||
355
mobile-app/ios/VaultUI/Views/CredentialProviderView.swift
Normal file
355
mobile-app/ios/VaultUI/Views/CredentialProviderView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
36
mobile-app/ios/VaultUITests/VaultUITests.swift
Normal file
36
mobile-app/ios/VaultUITests/VaultUITests.swift
Normal 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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user