mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-20 23:52:31 -04:00
Convert UIKit to SwiftUI (#771)
This commit is contained in:
204
mobile-app/ios/autofill/CredentialProviderView.swift
Normal file
204
mobile-app/ios/autofill/CredentialProviderView.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import SwiftUI
|
||||
import AuthenticationServices
|
||||
|
||||
struct CredentialProviderView: View {
|
||||
@ObservedObject var viewModel: CredentialProviderViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView("Loading credentials...")
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(1.5)
|
||||
} else {
|
||||
List(viewModel.credentials, id: \.service) { credential in
|
||||
Button(action: {
|
||||
viewModel.selectCredential(credential)
|
||||
}) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(credential.service)
|
||||
.font(.headline)
|
||||
Text(credential.username)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
viewModel.loadCredentials()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Credential")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
viewModel.cancel()
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.loadCredentials()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
|
||||
Button("Add") {
|
||||
viewModel.showAddCredential = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showAddCredential) {
|
||||
AddCredentialView(viewModel: viewModel)
|
||||
}
|
||||
.alert("Error", isPresented: $viewModel.showError) {
|
||||
Button("OK") {
|
||||
viewModel.cancel()
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage)
|
||||
}
|
||||
.task {
|
||||
// wait for .1sec
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.loadCredentials()
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CredentialProviderViewModel: ObservableObject {
|
||||
@Published var credentials: [Credential] = []
|
||||
@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 {
|
||||
credentials = try SharedCredentialStore.shared.getAllCredentials(createKeyIfNeeded: false)
|
||||
|
||||
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 selectCredential(_ credential: Credential) {
|
||||
let passwordCredential = ASPasswordCredential(user: credential.username,
|
||||
password: credential.password)
|
||||
extensionContext?.completeRequest(withSelectedCredential: passwordCredential,
|
||||
completionHandler: nil)
|
||||
}
|
||||
|
||||
func addCredential() {
|
||||
let credential = Credential(username: newUsername,
|
||||
password: newPassword,
|
||||
service: newService)
|
||||
|
||||
do {
|
||||
try SharedCredentialStore.shared.addCredential(credential, createKeyIfNeeded: false)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,204 +6,65 @@
|
||||
//
|
||||
|
||||
import AuthenticationServices
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
var credentials: [Credential] = []
|
||||
private let tableView = UITableView()
|
||||
private let addButton = UIButton(type: .system)
|
||||
private let loadButton = UIButton(type: .system)
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
loadCredentials()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue))
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
// Setup Cancel Button
|
||||
cancelButton.setTitle("Cancel", for: .normal)
|
||||
cancelButton.setTitleColor(.systemRed, for: .normal)
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
view.addSubview(cancelButton)
|
||||
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
let viewModel = CredentialProviderViewModel(extensionContext: extensionContext)
|
||||
let hostingController = UIHostingController(
|
||||
rootView: CredentialProviderView(viewModel: viewModel)
|
||||
)
|
||||
|
||||
// Setup Loading Indicator
|
||||
loadingIndicator.hidesWhenStopped = false
|
||||
loadingIndicator.color = .systemBlue
|
||||
view.addSubview(loadingIndicator)
|
||||
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
loadingIndicator.startAnimating()
|
||||
addChild(hostingController)
|
||||
view.addSubview(hostingController.view)
|
||||
|
||||
// Setup TableView
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "CredentialCell")
|
||||
view.addSubview(tableView)
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Setup Add Button
|
||||
addButton.setTitle("Add Credential", for: .normal)
|
||||
addButton.addTarget(self, action: #selector(addCredentialTapped), for: .touchUpInside)
|
||||
view.addSubview(addButton)
|
||||
addButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Setup Load Button
|
||||
loadButton.setTitle("Load Credentials", for: .normal)
|
||||
loadButton.addTarget(self, action: #selector(loadCredentials), for: .touchUpInside)
|
||||
view.addSubview(loadButton)
|
||||
loadButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Setup Constraints
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
|
||||
cancelButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
|
||||
cancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
cancelButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
tableView.topAnchor.constraint(equalTo: cancelButton.bottomAnchor, constant: 8),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: loadButton.topAnchor),
|
||||
|
||||
loadButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
loadButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||
loadButton.bottomAnchor.constraint(equalTo: addButton.topAnchor, constant: -8),
|
||||
loadButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
addButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
addButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||
addButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16),
|
||||
addButton.heightAnchor.constraint(equalToConstant: 44)
|
||||
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)
|
||||
])
|
||||
}
|
||||
|
||||
@objc private func loadCredentials() {
|
||||
do {
|
||||
credentials = try SharedCredentialStore.shared.getAllCredentials(createKeyIfNeeded: false)
|
||||
// Update credential identities in the system
|
||||
Task {
|
||||
try await CredentialIdentityStore.shared.saveCredentialIdentities(credentials)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.loadingIndicator.stopAnimating()
|
||||
self?.tableView.isHidden = false
|
||||
self?.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
} catch let error as NSError {
|
||||
loadingIndicator.stopAnimating()
|
||||
let errorAlert = UIAlertController(
|
||||
title: "Error Loading Credentials",
|
||||
message: error.localizedDescription,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
errorAlert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in
|
||||
self?.extensionContext.cancelRequest(withError: error)
|
||||
})
|
||||
present(errorAlert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func addCredentialTapped() {
|
||||
let alert = UIAlertController(title: "Add Credential", message: nil, preferredStyle: .alert)
|
||||
|
||||
alert.addTextField { textField in
|
||||
textField.placeholder = "Username"
|
||||
}
|
||||
alert.addTextField { textField in
|
||||
textField.placeholder = "Password"
|
||||
textField.isSecureTextEntry = true
|
||||
}
|
||||
alert.addTextField { textField in
|
||||
textField.placeholder = "Service"
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self] _ in
|
||||
guard let username = alert.textFields?[0].text,
|
||||
let password = alert.textFields?[1].text,
|
||||
let service = alert.textFields?[2].text,
|
||||
!username.isEmpty, !password.isEmpty, !service.isEmpty else { return }
|
||||
|
||||
let credential = Credential(username: username, password: password, service: service)
|
||||
do {
|
||||
try SharedCredentialStore.shared.addCredential(credential, createKeyIfNeeded: false)
|
||||
// Update credential identities in the system
|
||||
Task {
|
||||
try await CredentialIdentityStore.shared.saveCredentialIdentities([credential])
|
||||
}
|
||||
} catch let error as NSError {
|
||||
let errorAlert = UIAlertController(
|
||||
title: "Error Adding Credential",
|
||||
message: error.localizedDescription,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
errorAlert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
self?.present(errorAlert, animated: true)
|
||||
return
|
||||
}
|
||||
self?.loadCredentials()
|
||||
})
|
||||
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue))
|
||||
hostingController.didMove(toParent: self)
|
||||
}
|
||||
|
||||
override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
|
||||
// Loading it from here doesn't play well with the
|
||||
//loadCredentials()
|
||||
// This is handled in the SwiftUI view's onAppear
|
||||
}
|
||||
|
||||
override func prepareInterfaceForUserChoosingTextToInsert() {
|
||||
loadCredentials()
|
||||
// This is handled in the SwiftUI view's onAppear
|
||||
}
|
||||
|
||||
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
|
||||
loadCredentials()
|
||||
|
||||
// For testing purposes: we just return the first credential.
|
||||
if let firstCredential = credentials.first {
|
||||
let passwordCredential = ASPasswordCredential(user: firstCredential.username, password: firstCredential.password)
|
||||
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
|
||||
} else {
|
||||
self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.credentialIdentityNotFound.rawValue))
|
||||
// Get credentials and return the first one that matches the identity
|
||||
do {
|
||||
let credentials = try SharedCredentialStore.shared.getAllCredentials(createKeyIfNeeded: false)
|
||||
if let matchingCredential = credentials.first(where: { $0.service == credentialIdentity.serviceIdentifier.identifier }) {
|
||||
let passwordCredential = ASPasswordCredential(
|
||||
user: matchingCredential.username,
|
||||
password: matchingCredential.password
|
||||
)
|
||||
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
|
||||
} else {
|
||||
self.extensionContext.cancelRequest(
|
||||
withError: NSError(
|
||||
domain: ASExtensionErrorDomain,
|
||||
code: ASExtensionError.credentialIdentityNotFound.rawValue
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
self.extensionContext.cancelRequest(
|
||||
withError: NSError(
|
||||
domain: ASExtensionErrorDomain,
|
||||
code: ASExtensionError.failed.rawValue,
|
||||
userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CredentialProviderViewController: UITableViewDelegate, UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return credentials.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "CredentialCell", for: indexPath)
|
||||
let credential = credentials[indexPath.row]
|
||||
cell.textLabel?.text = "\(credential.service) - \(credential.username)"
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let credential = credentials[indexPath.row]
|
||||
let passwordCredential = ASPasswordCredential(user: credential.username, password: credential.password)
|
||||
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user