Tweak quick autofill flow on iOS with explicit loading view (#520)

This commit is contained in:
Leendert de Borst
2025-10-18 19:22:40 +02:00
parent 026cfb91e9
commit ee9f3ca0f9
6 changed files with 209 additions and 67 deletions

View File

@@ -29,6 +29,8 @@
CE77826F2EA18A2F00A75E6F /* VaultUtils.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE77825D2EA1822400A75E6F /* VaultUtils.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
CE7782732EA18A3500A75E6F /* VaultUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE77825D2EA1822400A75E6F /* VaultUtils.framework */; };
CE7782742EA18A3500A75E6F /* VaultUtils.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE77825D2EA1822400A75E6F /* VaultUtils.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
CE7782782EA3E70B00A75E6F /* VaultModels.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEE482AA2DBE8EFE00F4A367 /* VaultModels.framework */; };
CE7782792EA3E70B00A75E6F /* VaultModels.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CEE482AA2DBE8EFE00F4A367 /* VaultModels.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
CE9A58FC2DBA982100CB0A4C /* RCTNativeVaultManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = CE9A58FB2DBA982100CB0A4C /* RCTNativeVaultManager.mm */; };
CE9A5A022DBAAE5000CB0A4C /* RCTNativeVaultManager.h in Sources */ = {isa = PBXBuildFile; fileRef = CE9A58FA2DBA982100CB0A4C /* RCTNativeVaultManager.h */; };
CED3AB3C2E70CF8700F3FDEB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED3AB3B2E70CF8700F3FDEB /* AppDelegate.swift */; };
@@ -84,6 +86,13 @@
remoteGlobalIDString = CE77825C2EA1822400A75E6F;
remoteInfo = VaultUtils;
};
CE77827A2EA3E70B00A75E6F /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = CEE482A92DBE8EFE00F4A367;
remoteInfo = VaultModels;
};
CEE480932DBE86DD00F4A367 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
@@ -158,6 +167,17 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
CE77827C2EA3E70B00A75E6F /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
CE7782792EA3E70B00A75E6F /* VaultModels.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
CEE480A12DBE86DD00F4A367 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -294,6 +314,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CE7782782EA3E70B00A75E6F /* VaultModels.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -581,10 +602,12 @@
CE7782592EA1822400A75E6F /* Sources */,
CE77825A2EA1822400A75E6F /* Frameworks */,
CE77825B2EA1822400A75E6F /* Resources */,
CE77827C2EA3E70B00A75E6F /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
CE77827B2EA3E70B00A75E6F /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
CE77825E2EA1822400A75E6F /* VaultUtils */,
@@ -1231,6 +1254,11 @@
target = CE77825C2EA1822400A75E6F /* VaultUtils */;
targetProxy = CE7782752EA18A3500A75E6F /* PBXContainerItemProxy */;
};
CE77827B2EA3E70B00A75E6F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = CEE482A92DBE8EFE00F4A367 /* VaultModels */;
targetProxy = CE77827A2EA3E70B00A75E6F /* PBXContainerItemProxy */;
};
CEE480942DBE86DD00F4A367 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = CEE480862DBE86DC00F4A367 /* VaultStoreKit */;
@@ -1535,7 +1563,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
BUILD_LIBRARY_FOR_DISTRIBUTION = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -1557,7 +1585,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1591,7 +1619,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
BUILD_LIBRARY_FOR_DISTRIBUTION = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -1613,7 +1641,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@@ -67,50 +67,43 @@ extension CredentialProviderViewController: CredentialProviderDelegate {
isChoosingTextToInsert = true
// This will be handled by the credential view model when it's created
}
override public func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
// QuickType bar suggestions are disabled on iOS <26, so this should only be called on iOS 26+
if #unavailable(iOS 26.0) {
// iOS < 26 - we do not support quick autofill due to buggy behavior
self.extensionContext.cancelRequest(
withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.userInteractionRequired.rawValue
)
)
return
}
/**
* Handle quick return password credential request
* Called from viewWillAppear when in quick return mode with vault already unlocked
* Ensures minimum 700ms duration for smooth UX (prevents flash/jitter)
*/
internal func handleQuickReturnPasswordCredential(vaultStore: VaultStore, request: ASPasswordCredentialRequest) {
// Track start time for minimum duration
let startTime = Date()
let minimumDuration: TimeInterval = 0.7 // 700ms
do {
let vaultStore = VaultStore()
// Check if vault database exists
guard vaultStore.hasEncryptedDatabase else {
self.extensionContext.cancelRequest(
withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.userInteractionRequired.rawValue
)
)
return
}
// Unlock vault with biometrics
try vaultStore.unlockVault()
let credentials = try vaultStore.getAllCredentials()
if let matchingCredential = credentials.first(where: { credential in
return credential.id.uuidString == credentialIdentity.recordIdentifier
return credential.id.uuidString == request.credentialIdentity.recordIdentifier
}) {
// Ensure minimum duration before completing
let elapsed = Date().timeIntervalSince(startTime)
if elapsed < minimumDuration {
Thread.sleep(forTimeInterval: minimumDuration - elapsed)
}
// Use the identifier that matches the credential identity
let identifier = credentialIdentity.user
let identifier = request.credentialIdentity.user
let passwordCredential = ASPasswordCredential(
user: identifier,
password: matchingCredential.password?.value ?? ""
)
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
} else {
// Ensure minimum duration even on error
let elapsed = Date().timeIntervalSince(startTime)
if elapsed < minimumDuration {
Thread.sleep(forTimeInterval: minimumDuration - elapsed)
}
self.extensionContext.cancelRequest(
withError: NSError(
domain: ASExtensionErrorDomain,
@@ -119,12 +112,18 @@ extension CredentialProviderViewController: CredentialProviderDelegate {
)
}
} catch {
print("provideCredentialWithoutUserInteraction error: \(error)")
// On any error, request user interaction
// Ensure minimum duration even on error
let elapsed = Date().timeIntervalSince(startTime)
if elapsed < minimumDuration {
Thread.sleep(forTimeInterval: minimumDuration - elapsed)
}
print("handleQuickReturnPasswordCredential error: \(error)")
self.extensionContext.cancelRequest(
withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.userInteractionRequired.rawValue
code: ASExtensionError.failed.rawValue,
userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]
)
)
}

View File

@@ -34,20 +34,16 @@ extension CredentialProviderViewController: PasskeyProviderDelegate {
}
/**
* Provide passkey credential without user interaction
* Handle quick return passkey credential request
* Called from viewWillAppear when in quick return mode with vault already unlocked
* Ensures minimum 700ms duration for smooth UX (prevents flash/jitter)
*/
internal func providePasskeyCredentialWithoutUserInteraction(for request: ASPasskeyCredentialRequest) {
internal func handleQuickReturnPasskeyCredential(vaultStore: VaultStore, request: ASPasskeyCredentialRequest) {
// Track start time for minimum duration
let startTime = Date()
let minimumDuration: TimeInterval = 0.7 // 700ms
do {
let vaultStore = VaultStore()
// Check vault state
guard sanityChecks(vaultStore: vaultStore) else {
return
}
// Unlock vault
try vaultStore.unlockVault()
let clientDataHash = request.clientDataHash
let credentialIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity
let rpId = credentialIdentity?.relyingPartyIdentifier ?? ""
@@ -55,6 +51,12 @@ extension CredentialProviderViewController: PasskeyProviderDelegate {
// Look up passkey by credential ID
guard let passkey = try vaultStore.getPasskey(byCredentialId: credentialID) else {
// Ensure minimum duration even on error
let elapsed = Date().timeIntervalSince(startTime)
if elapsed < minimumDuration {
Thread.sleep(forTimeInterval: minimumDuration - elapsed)
}
extensionContext.cancelRequest(withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.credentialIdentityNotFound.rawValue
@@ -94,6 +96,12 @@ extension CredentialProviderViewController: PasskeyProviderDelegate {
)
let extensionOutput = ASPasskeyAssertionCredentialExtensionOutput(prf: prfOutput)
// Ensure minimum duration before completing
let elapsed = Date().timeIntervalSince(startTime)
if elapsed < minimumDuration {
Thread.sleep(forTimeInterval: minimumDuration - elapsed)
}
// Complete the request with extension output
let credential = ASPasskeyAssertionCredential(
userHandle: assertion.userHandle ?? Data(),
@@ -109,6 +117,12 @@ extension CredentialProviderViewController: PasskeyProviderDelegate {
return
}
// Ensure minimum duration before completing
let elapsed = Date().timeIntervalSince(startTime)
if elapsed < minimumDuration {
Thread.sleep(forTimeInterval: minimumDuration - elapsed)
}
// Complete the request without PRF extension output
let credential = ASPasskeyAssertionCredential(
userHandle: assertion.userHandle ?? Data(),
@@ -122,11 +136,17 @@ extension CredentialProviderViewController: PasskeyProviderDelegate {
extensionContext.completeAssertionRequest(using: credential)
} catch {
print("Passkey authentication without UI error: \(error)")
// Require user interaction if we can't authenticate silently
// Ensure minimum duration even on error
let elapsed = Date().timeIntervalSince(startTime)
if elapsed < minimumDuration {
Thread.sleep(forTimeInterval: minimumDuration - elapsed)
}
print("handleQuickReturnPasskeyCredential error: \(error)")
extensionContext.cancelRequest(withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.userInteractionRequired.rawValue
code: ASExtensionError.failed.rawValue,
userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]
))
}
}

View File

@@ -36,6 +36,11 @@ public class CredentialProviderViewController: ASCredentialProviderViewControlle
private var initialRpId: String?
private var clientDataHash: Data?
// Quick return mode (complete request without showing UI)
internal var isQuickReturnMode = false
internal var quickReturnPasswordRequest: ASPasswordCredentialRequest?
internal var quickReturnPasskeyRequest: ASPasskeyCredentialRequest?
// Delegates for specific credential types
weak var credentialDelegate: CredentialProviderDelegate?
weak var passkeyDelegate: PasskeyProviderDelegate?
@@ -61,6 +66,29 @@ public class CredentialProviderViewController: ASCredentialProviderViewControlle
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Check if we're in quick return mode
// Setup the loading view (actual unlock happens in viewDidAppear)
if isQuickReturnMode {
// Show loading view
let loadingView = QuickUnlockLoadingView()
let hostingController = UIHostingController(rootView: loadingView)
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
hostingController.didMove(toParent: self)
self.currentHostingController = hostingController
return
}
// Don't set up credential view if we're in passkey registration mode
if isPasskeyRegistrationMode {
return
@@ -89,6 +117,7 @@ public class CredentialProviderViewController: ASCredentialProviderViewControlle
return
}
// Only set up the view if we haven't already
if currentHostingController == nil {
do {
@@ -113,6 +142,33 @@ public class CredentialProviderViewController: ASCredentialProviderViewControlle
}
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// If we're in quick return mode, now trigger the unlock and complete the request
// The loading view is already visible from viewWillAppear
if isQuickReturnMode {
let vaultStore = VaultStore()
do {
try vaultStore.unlockVault()
if let passkeyRequest = quickReturnPasskeyRequest {
handleQuickReturnPasskeyCredential(vaultStore: vaultStore, request: passkeyRequest)
} else if let passwordRequest = quickReturnPasswordRequest {
handleQuickReturnPasswordCredential(vaultStore: vaultStore, request: passwordRequest)
}
} catch {
print("Quick return vault unlock failed: \(error)")
self.extensionContext.cancelRequest(withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.failed.rawValue,
userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]
))
}
}
}
private func setupView(vaultStore: VaultStore) throws {
let hostingController: UIViewController
@@ -175,26 +231,34 @@ public class CredentialProviderViewController: ASCredentialProviderViewControlle
// MARK: - Passkey Support
override public func provideCredentialWithoutUserInteraction(for credentialRequest: ASCredentialRequest) {
// Check if this is a passkey request
if let passkeyRequest = credentialRequest as? ASPasskeyCredentialRequest {
providePasskeyCredentialWithoutUserInteraction(for: passkeyRequest)
return
}
// For password credentials, delegate to credential extension
if let credentialIdentity = credentialRequest.credentialIdentity as? ASPasswordCredentialIdentity {
// This should call the credential extension's provideCredentialWithoutUserInteraction method
provideCredentialWithoutUserInteraction(for: credentialIdentity)
return
}
// Unknown credential type
// Always cancel and let iOS invoke prepareInterfaceToProvideCredential instead.
self.extensionContext.cancelRequest(withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.userInteractionRequired.rawValue
))
}
override public func prepareInterfaceToProvideCredential(for request: ASCredentialRequest) {
// Check if this is a password/credential request
if let passwordRequest = request as? ASPasswordCredentialRequest {
// We don't unlock vault here - that requires user interaction context
// which will happen in viewWillAppear
self.isQuickReturnMode = true
self.quickReturnPasswordRequest = passwordRequest
return
}
// Check if this is a passkey request
if let passkeyRequest = request as? ASPasskeyCredentialRequest {
// Store request and set quick return mode flag
// We don't unlock vault here - that requires user interaction context
// which will happen in viewWillAppear
self.isQuickReturnMode = true
self.quickReturnPasskeyRequest = passkeyRequest
return
}
}
/// Run sanity checks on the vault store before opening the autofill view to check things like if user is logged in,
/// vault is available etc.
/// - Returns

View File

@@ -0,0 +1,31 @@
import SwiftUI
/// Loading view shown during quick unlock (biometric authentication)
public struct QuickUnlockLoadingView: View {
@Environment(\.colorScheme) private var colorScheme
private let locBundle = Bundle.vaultUI
public init() {}
public var body: some View {
ZStack {
// Background
Color(colorScheme == .dark ? ColorConstants.Dark.background : ColorConstants.Light.background)
.ignoresSafeArea()
// Loading overlay
LoadingOverlayView(message: String(localized: "retrieving_credential", bundle: locBundle))
}
}
}
#Preview("Light Mode") {
QuickUnlockLoadingView()
.preferredColorScheme(.light)
}
#Preview("Dark Mode") {
QuickUnlockLoadingView()
.preferredColorScheme(.dark)
}

View File

Binary file not shown.