mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-09 18:29:12 -04:00
Tweak quick autofill flow on iOS with explicit loading view (#520)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user