From ee9f3ca0f9fd1bf4b05057fec79dd5fc72b7af43 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 18 Oct 2025 19:22:40 +0200 Subject: [PATCH] Tweak quick autofill flow on iOS with explicit loading view (#520) --- .../ios/AliasVault.xcodeproj/project.pbxproj | 36 ++++++- ...ialProviderViewController+Credential.swift | 67 +++++++------ ...entialProviderViewController+Passkey.swift | 50 +++++++--- .../CredentialProviderViewController.swift | 92 +++++++++++++++--- .../QuickUnlock/QuickUnlockLoadingView.swift | 31 ++++++ .../ios/VaultUI/en.lproj/Localizable.strings | Bin 5238 -> 5340 bytes 6 files changed, 209 insertions(+), 67 deletions(-) create mode 100644 apps/mobile-app/ios/VaultUI/QuickUnlock/QuickUnlockLoadingView.swift diff --git a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj index e517b3831..c95343861 100644 --- a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj +++ b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj @@ -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", diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift index c03ebf2dc..815e69e82 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Credential.swift @@ -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] ) ) } diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift index 0f1e1acd3..9e8a0d1b0 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift @@ -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] )) } } diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift index 47ab00140..7a1d397ad 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift @@ -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 diff --git a/apps/mobile-app/ios/VaultUI/QuickUnlock/QuickUnlockLoadingView.swift b/apps/mobile-app/ios/VaultUI/QuickUnlock/QuickUnlockLoadingView.swift new file mode 100644 index 000000000..e87a60673 --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/QuickUnlock/QuickUnlockLoadingView.swift @@ -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) +} diff --git a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings index 2f1ff67b2d0149cd8773b13da5ef3c2bfe345feb..1c204537f71ae668249bc8368d11ce1eb4511451 100644 GIT binary patch delta 60 zcmeySaYu8*A2xm^h9ZVkh7ur|38c#=?v$OZ$HXNXge(Y@%40}pP?&g7bn`d123`Ol C8WA%9 delta 12 Tcmcbk`AuWPAGXa&>{Yw~DaZwR