Convert UIKit to SwiftUI (#771)

This commit is contained in:
Leendert de Borst
2025-04-09 17:55:09 +02:00
parent ae5b4e070f
commit 82304029bf
2 changed files with 244 additions and 179 deletions

View 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
}
}
}

View File

@@ -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)
}
}