Auto filter native credentials list based on provided URL (#771)

This commit is contained in:
Leendert de Borst
2025-04-28 12:26:06 +02:00
parent 9f79c0cfeb
commit 01f1cc8bc3
3 changed files with 163 additions and 24 deletions

View File

@@ -20,6 +20,8 @@ import VaultModels
* logins in the keyboard).
*/
class CredentialProviderViewController: ASCredentialProviderViewController {
private var viewModel: CredentialProviderViewModel?
override func viewDidLoad() {
super.viewDidLoad()
@@ -46,6 +48,8 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
}
)
self.viewModel = viewModel
let hostingController = UIHostingController(
rootView: CredentialProviderView(viewModel: viewModel)
)
@@ -65,7 +69,13 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
}
override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
// This is handled in the SwiftUI view's onAppear
guard let viewModel = self.viewModel else { return }
// Instead of directly filtering credentials, just set the search text
let matchedDomains = serviceIdentifiers.map { $0.identifier.lowercased() }
if let firstDomain = matchedDomains.first {
viewModel.setSearchFilter(firstDomain)
}
}
override func prepareInterfaceForUserChoosingTextToInsert() {
@@ -73,11 +83,6 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
}
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
// Get credentials and return the first one that matches the identity
// TODO: how do we handle authentication here? We need Face ID access before we can access credentials..
// so we probably actually need to have a .shared instance in the autofill extension where after one unlock
// it stays unlocked? Or should we cache usernames locally and still require faceid as soon as user tries to
// autofill the username? Check how this should work functionally.
do {
let credentials = try VaultStore.shared.getAllCredentials()
if let matchingCredential = credentials.first(where: { $0.service.name ?? "" == credentialIdentity.serviceIdentifier.identifier }) {

View File

@@ -20,11 +20,11 @@ struct CredentialCard: View {
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 4) {
Text(credential.service.name ?? "Unknown Service")
Text(truncateText(credential.service.name ?? "Unknown", limit: 26))
.font(.headline)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
Text(credential.username ?? "No username")
Text(truncateText(usernameOrEmail(credential: credential), limit: 26))
.font(.subheadline)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.textMuted : ColorConstants.Light.textMuted)
}
@@ -47,6 +47,25 @@ struct CredentialCard: View {
}
}
func usernameOrEmail(credential: Credential) -> String {
if let username = credential.username, !username.isEmpty {
return username
}
if let email = credential.alias?.email, !email.isEmpty {
return email
}
return ""
}
func truncateText(_ text: String?, limit: Int) -> String {
guard let text = text else { return "" }
if text.count > limit {
let index = text.index(text.startIndex, offsetBy: limit)
return String(text[..<index]) + "..."
} else {
return text
}
}
#Preview {
CredentialCard(
@@ -66,14 +85,14 @@ struct CredentialCard: View {
),
service: Service(
id: UUID(),
name: "Example Service",
name: "Example Service with a very long name bla bla bla",
url: "https://example.com",
logo: nil,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
),
username: "johndoe",
username: "usernameverylongverylongtextindeed",
notes: "Sample notes",
password: Password(
id: UUID(),

View File

@@ -7,7 +7,7 @@ public struct CredentialProviderView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
public init(viewModel: CredentialProviderViewModel) {
self._viewModel = ObservedObject(wrappedValue: viewModel)
}
@@ -33,15 +33,50 @@ public struct CredentialProviderView: View {
}
ScrollView {
LazyVStack(spacing: 8) {
ForEach(viewModel.filteredCredentials, id: \.service) { credential in
CredentialCard(credential: credential) {
viewModel.selectCredential(credential)
if viewModel.filteredCredentials.isEmpty {
VStack(spacing: 20) {
Image(systemName: "magnifyingglass")
.font(.system(size: 50))
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
Text("No credentials found")
.font(.headline)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
Text("No existing credentials match your search")
.font(.subheadline)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
.multilineTextAlignment(.center)
VStack(spacing: 12) {
Button(action: {
viewModel.showAddCredential = true
}) {
HStack {
Image(systemName: "plus.circle.fill")
Text("Create New Credential")
}
.padding()
.frame(maxWidth: .infinity)
.background(ColorConstants.Light.primary)
.foregroundColor(.white)
.cornerRadius(8)
}
}
.padding(.horizontal, 40)
}
.padding(.top, 60)
} else {
LazyVStack(spacing: 8) {
ForEach(viewModel.filteredCredentials, id: \.service) { credential in
CredentialCard(credential: credential) {
viewModel.selectCredential(credential)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
}
.padding(.horizontal)
.padding(.top, 8)
}
.refreshable {
await viewModel.loadCredentials()
@@ -61,13 +96,6 @@ public struct CredentialProviderView: View {
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
}
@@ -125,6 +153,12 @@ public class CredentialProviderViewModel: ObservableObject {
self.cancelHandler = cancelHandler
}
@MainActor
public func setSearchFilter(_ text: String) {
self.searchText = text
self.filterCredentials()
}
@MainActor
func loadCredentials() async {
isLoading = true
@@ -140,7 +174,69 @@ public class CredentialProviderViewModel: ObservableObject {
func filterCredentials() {
if searchText.isEmpty {
filteredCredentials = credentials
return
}
func extractRootDomain(from urlString: String) -> String? {
guard let url = URL(string: urlString), let host = url.host else { return nil }
let parts = host.components(separatedBy: ".")
return parts.count >= 2 ? parts.suffix(2).joined(separator: ".") : host
}
func extractDomainWithoutExtension(from domain: String) -> String {
return domain.components(separatedBy: ".").first ?? domain
}
if let searchUrl = URL(string: searchText), let hostname = searchUrl.host, !hostname.isEmpty {
let baseUrl = "\(searchUrl.scheme ?? "https")://\(hostname)"
let rootDomain = extractRootDomain(from: searchUrl.absoluteString) ?? hostname
let domainWithoutExtension = extractDomainWithoutExtension(from: rootDomain)
// 1. Exact URL match
var matches = credentials.filter { credential in
if let serviceUrl = credential.service.url,
let url = URL(string: serviceUrl) {
return url.absoluteString.lowercased() == searchUrl.absoluteString.lowercased()
}
return false
}
// 2. Base URL match (excluding query/path)
if matches.isEmpty {
matches = credentials.filter { credential in
if let serviceUrl = credential.service.url,
let url = URL(string: serviceUrl) {
return url.absoluteString.lowercased().hasPrefix(baseUrl.lowercased())
}
return false
}
}
// 3. Root domain match (e.g., coolblue.nl)
if matches.isEmpty {
matches = credentials.filter { credential in
if let serviceUrl = credential.service.url,
let credRootDomain = extractRootDomain(from: serviceUrl) {
return credRootDomain.lowercased() == rootDomain.lowercased()
}
return false
}
}
// 4. Domain name part match (e.g., "coolblue" in service name)
if matches.isEmpty {
matches = credentials.filter { credential in
if let serviceName = credential.service.name?.lowercased() {
return serviceName.contains(domainWithoutExtension.lowercased()) ||
domainWithoutExtension.lowercased().contains(serviceName)
}
return false
}
}
filteredCredentials = matches
} else {
// Non-URL fallback: simple text search in service name or username
filteredCredentials = credentials.filter { credential in
(credential.service.name?.localizedCaseInsensitiveContains(searchText) ?? false) ||
(credential.username?.localizedCaseInsensitiveContains(searchText) ?? false)
@@ -341,6 +437,25 @@ class PreviewCredentialProviderViewModel: CredentialProviderViewModel {
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
),
Credential(
id: UUID(),
alias: .preview,
service: Service(
id: UUID(),
name: "Long name service with a lot of characters",
url: "https://another.com",
logo: nil,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
),
username: "usernameisalsoprettylongjusttoseewhathappens",
notes: "Another sample credential",
password: .preview,
createdAt: Date(),
updatedAt: Date(),
isDeleted: false
)
]