Files
IronStorage/apple/Sources/App/IronStorageApp.swift

5226 lines
194 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
import AVFoundation
import Vision
import VisionKit
extension Notification.Name {
static let ironStorageLocalStoreDidChange = Notification.Name(
"de.rfc1437.ironstorage.local-store-did-change"
)
static let ironStorageAuthenticationDidChange = Notification.Name(
"de.rfc1437.ironstorage.authentication-did-change"
)
static let ironStorageWatchSnapshotDidChange = Notification.Name(
"de.rfc1437.ironstorage.watch-snapshot-did-change"
)
static let ironStorageKeyMaterialDidChange = Notification.Name(
"de.rfc1437.ironstorage.key-material-did-change"
)
}
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var context: AppContext?
override init() {
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(keyMaterialDidChange),
name: .ironStorageKeyMaterialDidChange,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
let context = AppContext()
self.context = context
window.rootViewController = context.makeRootController()
window.makeKeyAndVisible()
self.window = window
return true
}
func applicationDidEnterBackground(_ application: UIApplication) {
context?.lockForBackground()
}
@objc private func keyMaterialDidChange() {
guard let window else { return }
context?.lockForBackground()
let context = AppContext()
self.context = context
window.rootViewController = context.makeRootController()
}
}
@MainActor
private protocol MobileTabRoot: AnyObject {
var shellTab: MobileTab { get }
}
@MainActor
private final class AppContext: NSObject, UITabBarControllerDelegate {
private let tabs = UITabBarController()
private let authentication = try? mobileAuthentication()
private var navigationControllers: [UINavigationController] = []
private var restoreTask: Task<Void, Never>?
private var authenticationMonitor: Task<Void, Never>?
deinit {
restoreTask?.cancel()
authenticationMonitor?.cancel()
}
func makeRootController() -> UIViewController {
let shell = mobileShellFixture(state: .loading)
navigationControllers = shell.pages.map { page in
let root: UIViewController = switch page.tab {
case .passwords:
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
case .search:
PasswordSearchViewController(shellPage: page, authentication: authentication)
case .totp:
TotpListViewController(shellPage: page, authentication: authentication)
case .preferences:
PreferencesViewController(page: page, authentication: authentication)
default:
ShellViewController(page: page)
}
let navigation = UINavigationController(rootViewController: root)
navigation.navigationBar.prefersLargeTitles = true
navigation.tabBarItem = UITabBarItem(
title: page.title,
image: UIImage(systemName: page.systemImage),
selectedImage: UIImage(systemName: page.selectedSystemImage)
)
return navigation
}
tabs.viewControllers = navigationControllers
tabs.delegate = self
restoreSelectedTab()
monitorAuthentication()
return tabs
}
func lockForBackground() {
guard let authentication else { return }
try? authentication.manualLock()
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
}
func tabBarController(
_ tabBarController: UITabBarController,
didSelect viewController: UIViewController
) {
guard
let navigation = viewController as? UINavigationController,
let root = navigation.viewControllers.first as? MobileTabRoot
else {
return
}
do {
try setSelectedMobileTab(tab: root.shellTab)
} catch {
let alert = UIAlertController(
title: "Selection Was Not Saved",
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
tabs.present(alert, animated: true)
}
}
private func restoreSelectedTab() {
restoreTask?.cancel()
restoreTask = Task { [weak self] in
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
guard !Task.isCancelled, let self else { return }
if let index = shell.pages.firstIndex(where: { $0.tab == shell.selectedTab }) {
tabs.selectedIndex = index
}
}
}
private func monitorAuthentication() {
authenticationMonitor?.cancel()
guard let authentication else { return }
authenticationMonitor = Task {
var wasUnlocked = (try? authentication.state().unlocked) ?? false
while !Task.isCancelled {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
let isUnlocked = (try? authentication.state().unlocked) ?? false
if isUnlocked != wasUnlocked {
wasUnlocked = isUnlocked
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
}
}
}
}
}
@MainActor
private final class ShellViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab: MobileTab
private var page: MobilePage
private var loadTask: Task<Void, Never>?
private var loadGeneration = 0
private var homePage: MobileHomePage?
private var homeTask: Task<Void, Never>?
private var homeProgressTask: Task<Void, Never>?
private var homeOperation: MobileHomeOperation?
private var homeGeneration = 0
private var isHomeWorking = false
private enum HomeSection {
case summary
case incoming
case outgoing
case empty
case notice
}
private enum HomeRequest: Equatable {
case refreshIfStale
case refresh
case pull
}
init(page: MobilePage) {
shellTab = page.tab
self.page = page
super.init(style: .insetGrouped)
title = page.title
navigationItem.largeTitleDisplayMode = .always
refreshControl = UIRefreshControl()
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
loadTask?.cancel()
homeOperation?.cancel()
homeTask?.cancel()
homeProgressTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
apply(page)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadShell()
if shellTab == .home, page.state == .ready, homePage != nil {
runHome(.refreshIfStale)
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadTask?.cancel()
cancelHomeWork()
}
override func numberOfSections(in tableView: UITableView) -> Int {
guard page.state == .ready else { return 0 }
if shellTab == .home {
return homeSections.count
}
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard shellTab == .home else { return 1 }
switch homeSections[section] {
case .summary: return homePage?.summaries.count ?? 0
case .incoming: return homePage?.incoming.count ?? 0
case .outgoing: return homePage?.outgoing.count ?? 0
case .empty, .notice: return 1
}
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
guard shellTab == .home else { return nil }
switch homeSections[section] {
case .summary: return "Status"
case .incoming: return "Incoming Activity"
case .outgoing: return "Outgoing Activity"
case .empty: return "Remote Activity"
case .notice: return "Result"
}
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
guard shellTab == .home, let homePage else { return nil }
switch homeSections[section] {
case .summary:
return freshnessDescription(homePage)
case .incoming where homePage.incomingTotal > UInt32(homePage.incoming.count):
return "Showing \(homePage.incoming.count) of \(homePage.incomingTotal) incoming commits."
case .outgoing where homePage.outgoingTotal > UInt32(homePage.outgoing.count):
return "Showing \(homePage.outgoing.count) of \(homePage.outgoingTotal) outgoing commits."
default:
return nil
}
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
if shellTab == .home {
return homeCell(at: indexPath)
}
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: page.systemImage)
content.text = page.stateTitle
content.secondaryText = page.stateDetail
content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content
cell.selectionStyle = .none
cell.accessibilityLabel = "\(page.stateTitle). \(page.stateDetail)"
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
guard shellTab == .home, let homePage else { return }
let section = homeSections[indexPath.section]
let commit: MobileHomeCommit
let incoming: Bool
switch section {
case .incoming:
commit = homePage.incoming[indexPath.row]
incoming = true
case .outgoing:
commit = homePage.outgoing[indexPath.row]
incoming = false
default:
return
}
navigationController?.pushViewController(
MobileCommitActivityViewController(commit: commit, incoming: incoming),
animated: true
)
}
@objc private func refreshRequested() {
if shellTab == .home, page.state == .ready {
guard !isHomeWorking else {
refreshControl?.endRefreshing()
return
}
runHome(.pull)
} else {
reloadShell()
}
}
@objc private func localStoreDidChange() {
guard shellTab != .home else { return }
reloadShell()
}
private func reloadShell() {
loadGeneration += 1
let generation = loadGeneration
loadTask?.cancel()
loadTask = Task { [weak self] in
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
guard
!Task.isCancelled,
let self,
generation == loadGeneration,
let page = shell.pages.first(where: { $0.tab == self.shellTab })
else { return }
apply(page)
}
}
private func apply(_ page: MobilePage) {
self.page = page
title = page.title
if page.state == .empty, shellTab == .home {
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Set Up",
style: .done,
target: self,
action: #selector(setupRequested)
)
} else if page.state == .ready, shellTab == .home {
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "arrow.clockwise"),
style: .plain,
target: self,
action: #selector(statusRefreshRequested)
)
navigationItem.rightBarButtonItem?.accessibilityLabel = "Refresh remote status"
navigationItem.rightBarButtonItem?.isEnabled = !isHomeWorking
} else if page.state == .ready, shellTab == .preferences {
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Update Token",
style: .plain,
target: self,
action: #selector(tokenUpdateRequested)
)
} else {
navigationItem.rightBarButtonItem = nil
}
refreshControl?.endRefreshing()
tableView.reloadData()
guard page.state != .ready else {
contentUnavailableConfiguration = nil
if shellTab == .home {
loadHomeIfNeeded()
}
return
}
var configuration = page.state == .loading
? UIContentUnavailableConfiguration.loading()
: UIContentUnavailableConfiguration.empty()
configuration.text = page.stateTitle
configuration.secondaryText = page.stateDetail
configuration.image = UIImage(systemName: stateImage(page.state))
contentUnavailableConfiguration = configuration
}
@objc private func setupRequested() {
navigationController?.pushViewController(OnboardingViewController(), animated: true)
}
@objc private func tokenUpdateRequested() {
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
}
@objc private func statusRefreshRequested() {
runHome(.refresh)
}
private var homeSections: [HomeSection] {
guard let homePage else { return [] }
var sections: [HomeSection] = [.summary]
if !homePage.incoming.isEmpty {
sections.append(.incoming)
}
if !homePage.outgoing.isEmpty {
sections.append(.outgoing)
}
if homePage.incomingTotal == 0, homePage.outgoingTotal == 0 {
sections.append(.empty)
}
if homePage.notice != nil {
sections.append(.notice)
}
return sections
}
private func homeCell(at indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
guard let homePage else { return cell }
var content = cell.defaultContentConfiguration()
switch homeSections[indexPath.section] {
case .summary:
let row = homePage.summaries[indexPath.row]
content.image = UIImage(systemName: row.systemImage)
content.text = row.title
content.secondaryText = row.detail
cell.selectionStyle = .none
case .incoming:
configureCommit(
homePage.incoming[indexPath.row],
content: &content,
cell: cell
)
case .outgoing:
configureCommit(
homePage.outgoing[indexPath.row],
content: &content,
cell: cell
)
case .empty:
content.image = UIImage(systemName: "checkmark.circle")
content.text = "No Remote Activity"
content.secondaryText = "There are no commits to pull or push."
cell.selectionStyle = .none
case .notice:
if let notice = homePage.notice {
content.image = UIImage(systemName: notice.systemImage)
content.text = notice.title
content.secondaryText = notice.detail
}
content.secondaryTextProperties.numberOfLines = 0
cell.selectionStyle = .none
}
content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content
return cell
}
private func configureCommit(
_ commit: MobileHomeCommit,
content: inout UIListContentConfiguration,
cell: UITableViewCell
) {
content.image = UIImage(systemName: commit.systemImage)
content.text = commit.title
content.secondaryText = "\(commit.detail)\n\(formattedDate(commit.timestamp))"
content.secondaryTextProperties.numberOfLines = 0
cell.accessoryType = .disclosureIndicator
cell.accessibilityHint = "Shows password-store changes in this commit."
}
private func freshnessDescription(_ page: MobileHomePage) -> String {
let refreshed = page.refreshedAt.map(formattedDate)
switch page.freshness {
case .neverRefreshed:
return "Not refreshed. Pull down to update the local clone or tap Refresh Status."
case .cached:
return "Cached · Last refreshed \(refreshed ?? "at an unknown time"). This is not live status."
case .current:
return "Current · Refreshed \(refreshed ?? "just now")."
}
}
private func formattedDate(_ timestamp: Int64) -> String {
Date(timeIntervalSince1970: TimeInterval(timestamp)).formatted(
date: .abbreviated,
time: .shortened
)
}
private func loadHomeIfNeeded() {
guard homePage == nil, homeTask == nil, !isHomeWorking else { return }
homeGeneration += 1
let current = homeGeneration
let operation = mobileHomeOperation()
homeOperation = operation
isHomeWorking = true
navigationItem.rightBarButtonItem?.isEnabled = false
showHomeLoading()
homeTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileHomePage, HomeFailure>.success(try operation.cached())
} catch let error as MobileHomeFfiError {
return .failure(HomeFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard let self, current == homeGeneration else { return }
finishHome(result, request: nil)
if case .success = result {
runHome(.refreshIfStale)
}
}
}
private func runHome(_ request: HomeRequest) {
guard page.state == .ready, shellTab == .home, !isHomeWorking else { return }
cancelHomeWork()
homeGeneration += 1
let current = homeGeneration
let operation = mobileHomeOperation()
homeOperation = operation
isHomeWorking = true
navigationItem.rightBarButtonItem?.isEnabled = false
showHomeProgress(operation.progress())
homeProgressTask = Task { [weak self] in
while !Task.isCancelled {
do {
try await Task.sleep(for: .milliseconds(150))
} catch {
return
}
guard !Task.isCancelled, let self, current == homeGeneration else { return }
showHomeProgress(operation.progress())
}
}
homeTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
let page: MobileHomePage = switch request {
case .refreshIfStale: try operation.refreshIfStale()
case .refresh: try operation.refresh()
case .pull: try operation.pull()
}
return Result<MobileHomePage, HomeFailure>.success(page)
} catch let error as MobileHomeFfiError {
return .failure(HomeFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard let self, current == homeGeneration else { return }
finishHome(result, request: request)
}
}
private func finishHome(
_ result: Result<MobileHomePage, HomeFailure>,
request: HomeRequest?
) {
homeProgressTask?.cancel()
homeProgressTask = nil
homeTask = nil
homeOperation = nil
isHomeWorking = false
navigationItem.titleView = nil
navigationItem.prompt = nil
navigationItem.rightBarButtonItem?.isEnabled = true
refreshControl?.endRefreshing()
switch result {
case let .success(homePage):
self.homePage = homePage
contentUnavailableConfiguration = nil
tableView.reloadData()
if request == .pull {
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
if let notice = homePage.notice {
UIAccessibility.post(notification: .announcement, argument: notice.title)
}
}
case let .failure(failure):
tableView.reloadData()
if homePage == nil {
showHomeFailure(failure)
} else if failure.kind != .interrupted {
presentHomeFailure(failure)
}
}
}
private func showHomeLoading() {
var configuration = UIContentUnavailableConfiguration.loading()
configuration.text = "Loading Activity"
configuration.secondaryText = "Reading cached remote-branch state from storage."
contentUnavailableConfiguration = configuration
}
private func showHomeFailure(_ failure: HomeFailure) {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "exclamationmark.triangle")
configuration.text = failure.title
configuration.secondaryText = failure.detail
contentUnavailableConfiguration = configuration
}
private func presentHomeFailure(_ failure: HomeFailure) {
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
if failure.kind == .authentication || failure.kind == .secureStorage {
alert.addAction(UIAlertAction(title: "Preferences", style: .default) { [weak self] _ in
self?.tabBarController?.selectedIndex = 3
})
}
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
private func showHomeProgress(_ progress: MobileHomeProgress) {
let spinner = UIActivityIndicatorView(style: .medium)
spinner.startAnimating()
spinner.accessibilityLabel = "In progress"
let label = UILabel()
label.text = progress.title
label.font = .preferredFont(forTextStyle: .headline)
label.adjustsFontForContentSizeCategory = true
let stack = UIStackView(arrangedSubviews: [spinner, label])
stack.spacing = 8
navigationItem.titleView = stack
navigationItem.prompt = progress.detail
}
private func cancelHomeWork() {
homeGeneration += 1
homeOperation?.cancel()
homeOperation = nil
homeTask?.cancel()
homeTask = nil
homeProgressTask?.cancel()
homeProgressTask = nil
isHomeWorking = false
navigationItem.titleView = nil
navigationItem.prompt = nil
refreshControl?.endRefreshing()
navigationItem.rightBarButtonItem?.isEnabled = true
}
private func stateImage(_ state: MobileShellState) -> String {
switch state {
case .loading: "hourglass"
case .empty: "lock.shield"
case .ready: page.systemImage
case .locked: "lock.fill"
case .error: "exclamationmark.triangle"
}
}
}
@MainActor
private final class PreferencesViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.preferences
private var page: MobilePage
private let authentication: MobileAuthentication?
private let keyTransfer = try? mobileKeyTransfer()
private var state: MobileAuthenticationState?
private var preferenceTask: Task<Void, Never>?
private var loadTask: Task<Void, Never>?
private var loadGeneration = 0
init(page: MobilePage, authentication: MobileAuthentication?) {
self.page = page
self.authentication = authentication
super.init(style: .insetGrouped)
title = page.title
navigationItem.largeTitleDisplayMode = .always
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Update Token",
style: .plain,
target: self,
action: #selector(tokenUpdateRequested)
)
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageAuthenticationDidChange,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
preferenceTask?.cancel()
loadTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadShell()
}
override func numberOfSections(in tableView: UITableView) -> Int {
page.state == .ready ? 3 : 0
}
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
section == 0 ? 2 : (section == 1 ? 1 : 2)
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
switch section {
case 0: "GPG Key Transfer"
case 1: "Secure Unlock"
default: "Authentication Session"
}
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
if section == 0 {
return "Scan or display ASCII-armored GPG keys. Private-key transfers require explicit confirmation and passphrase validation."
}
if section == 1 {
return "When enabled, the GPG passphrase is device-only, requires a device passcode, and is invalidated when enrolled biometrics change."
}
return "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
if indexPath.section == 0 {
let importing = indexPath.row == 0
content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode")
content.text = importing ? "Import GPG Key" : "Export GPG Key"
content.secondaryText = importing
? "Scan one or more transfer QR codes"
: "Display public or private key armor"
cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = keyTransfer != nil
cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1
} else if indexPath.section == 1 {
content.image = UIImage(systemName: "faceid")
content.text = "Biometric Unlock"
content.secondaryText = state?.biometricUnlockEnabled == true
? "Protected passphrase enrolled"
: "Manual passphrase required"
let toggle = UISwitch()
toggle.isOn = state?.biometricUnlockEnabled == true
toggle.isEnabled = authentication != nil
toggle.addTarget(self, action: #selector(biometricToggleChanged(_:)), for: .valueChanged)
toggle.accessibilityLabel = "Biometric Unlock"
cell.accessoryView = toggle
cell.selectionStyle = .none
} else if indexPath.row == 0 {
let unlocked = state?.unlocked == true
content.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
content.text = unlocked ? "Unlocked" : "Locked"
content.secondaryText = unlocked
? "Locks in \(state?.remainingSeconds ?? 0) seconds without activity"
: "Protected content is masked"
cell.selectionStyle = .none
} else {
content.image = UIImage(systemName: "lock.fill")
content.text = "Lock Now"
content.textProperties.color = .systemRed
content.secondaryText = "Revoke all active authentication handles"
cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = state?.unlocked == true
cell.contentView.alpha = state?.unlocked == true ? 1 : 0.45
}
content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 0 {
guard let keyTransfer else { return }
if indexPath.row == 0 {
requestKeyScanner(keyTransfer)
} else {
navigationController?.pushViewController(
KeyExportListViewController(transfer: keyTransfer),
animated: true
)
}
return
}
guard indexPath.section == 2, indexPath.row == 1, let authentication else { return }
do {
try authentication.manualLock()
refreshState()
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
UIAccessibility.post(notification: .announcement, argument: "IronStorage locked")
} catch let error as MobileAuthenticationFfiError {
presentAuthenticationFailure(AuthenticationFailure(error))
} catch {
presentAuthenticationFailure(.unexpected)
}
}
override func viewDidLoad() {
super.viewDidLoad()
apply(page)
}
private func reloadShell() {
loadGeneration += 1
let generation = loadGeneration
loadTask?.cancel()
loadTask = Task { [weak self] in
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
guard
!Task.isCancelled,
let self,
generation == loadGeneration,
let page = shell.pages.first(where: { $0.tab == .preferences })
else { return }
apply(page)
}
}
private func apply(_ page: MobilePage) {
self.page = page
title = page.title
guard page.state == .ready else {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "gearshape")
configuration.text = page.stateTitle
configuration.secondaryText = page.stateDetail
contentUnavailableConfiguration = configuration
tableView.reloadData()
return
}
contentUnavailableConfiguration = nil
refreshState()
}
@objc private func tokenUpdateRequested() {
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
}
private func requestKeyScanner(_ transfer: MobileKeyTransfer) {
guard DataScannerViewController.isSupported else {
presentMessage(
title: "Key Scanning Is Unavailable",
detail: "This device does not support live QR scanning."
)
return
}
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
showKeyScanner(transfer)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
Task { @MainActor in
guard let self else { return }
if granted {
self.showKeyScanner(transfer)
} else {
self.showCameraDenied()
}
}
}
default:
showCameraDenied()
}
}
private func showKeyScanner(_ transfer: MobileKeyTransfer) {
guard DataScannerViewController.isAvailable else {
presentMessage(
title: "Camera Is Unavailable",
detail: "Close other camera apps and try again."
)
return
}
navigationController?.pushViewController(
KeyImportScannerViewController(transfer: transfer),
animated: true
)
}
private func showCameraDenied() {
let alert = UIAlertController(
title: "Camera Access Is Off",
message: "Allow camera access in Settings to scan GPG key QR codes.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Open Settings", style: .default) { _ in
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
})
present(alert, animated: true)
}
private func presentMessage(title: String, detail: String) {
let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
@objc private func authenticationDidChange() {
refreshState()
}
@objc private func biometricToggleChanged(_ sender: UISwitch) {
guard let authentication else {
sender.setOn(false, animated: true)
presentAuthenticationFailure(.unavailable)
return
}
sender.isEnabled = false
let enabled = sender.isOn
preferenceTask?.cancel()
preferenceTask = Task { [weak self, weak sender] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
try authentication.setBiometricUnlock(enabled: enabled)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
sender?.isEnabled = true
switch result {
case let .success(state):
self.state = state
tableView.reloadData()
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
case let .failure(failure):
sender?.setOn(!enabled, animated: true)
presentAuthenticationFailure(failure)
}
}
}
private func refreshState() {
state = try? authentication?.state()
tableView.reloadData()
}
}
@MainActor
private final class KeyImportScannerViewController: UIViewController,
DataScannerViewControllerDelegate
{
private let importer: MobileKeyTransferImport
private let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced,
recognizesMultipleItems: true,
isHighFrameRateTrackingEnabled: false,
isPinchToZoomEnabled: true,
isGuidanceEnabled: true,
isHighlightingEnabled: true
)
private let progressLabel = UILabel()
private let shield = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
private var completing = false
init(transfer: MobileKeyTransfer) {
importer = transfer.importer()
super.init(nibName: nil, bundle: nil)
title = "Import GPG Key"
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
scanner.delegate = self
addChild(scanner)
scanner.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scanner.view)
scanner.didMove(toParent: self)
let material = UIBlurEffect(style: .systemMaterial)
let status = UIVisualEffectView(effect: material)
status.layer.cornerRadius = 12
status.clipsToBounds = true
status.translatesAutoresizingMaskIntoConstraints = false
progressLabel.text = "Point the camera at a key-transfer QR code"
progressLabel.font = .preferredFont(forTextStyle: .callout)
progressLabel.adjustsFontForContentSizeCategory = true
progressLabel.numberOfLines = 0
progressLabel.textAlignment = .center
progressLabel.translatesAutoresizingMaskIntoConstraints = false
status.contentView.addSubview(progressLabel)
view.addSubview(status)
let shieldLabel = UILabel()
shieldLabel.text = "Camera hidden while IronStorage is inactive or the screen is captured"
shieldLabel.font = .preferredFont(forTextStyle: .headline)
shieldLabel.adjustsFontForContentSizeCategory = true
shieldLabel.numberOfLines = 0
shieldLabel.textAlignment = .center
shieldLabel.translatesAutoresizingMaskIntoConstraints = false
shield.contentView.addSubview(shieldLabel)
shield.translatesAutoresizingMaskIntoConstraints = false
shield.isHidden = true
shield.accessibilityViewIsModal = true
view.addSubview(shield)
NSLayoutConstraint.activate([
scanner.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scanner.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scanner.view.topAnchor.constraint(equalTo: view.topAnchor),
scanner.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
status.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
status.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20),
status.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
progressLabel.leadingAnchor.constraint(equalTo: status.contentView.leadingAnchor, constant: 16),
progressLabel.trailingAnchor.constraint(equalTo: status.contentView.trailingAnchor, constant: -16),
progressLabel.topAnchor.constraint(equalTo: status.contentView.topAnchor, constant: 12),
progressLabel.bottomAnchor.constraint(equalTo: status.contentView.bottomAnchor, constant: -12),
shield.leadingAnchor.constraint(equalTo: view.leadingAnchor),
shield.trailingAnchor.constraint(equalTo: view.trailingAnchor),
shield.topAnchor.constraint(equalTo: view.topAnchor),
shield.bottomAnchor.constraint(equalTo: view.bottomAnchor),
shieldLabel.leadingAnchor.constraint(equalTo: shield.contentView.leadingAnchor, constant: 32),
shieldLabel.trailingAnchor.constraint(equalTo: shield.contentView.trailingAnchor, constant: -32),
shieldLabel.centerYAnchor.constraint(equalTo: shield.contentView.centerYAnchor),
])
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
registerForTraitChanges([UITraitSceneCaptureState.self]) {
(controller: KeyImportScannerViewController, _: UITraitCollection) in
controller.updateCaptureShield()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !completing {
do {
try scanner.startScanning()
} catch {
presentFailure(error.localizedDescription)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
scanner.stopScanning()
super.viewWillDisappear(animated)
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]
) {
guard !completing else { return }
for item in addedItems {
guard case let .barcode(barcode) = item, let payload = barcode.payloadStringValue else {
continue
}
do {
let progress = try importer.addFrame(payload: payload)
if progress.duplicate {
progressLabel.text = "Already scanned — show the next QR code"
} else {
progressLabel.text = progress.key == nil
? "Scanned \(progress.received) of \(progress.total)"
: "Key transfer complete"
}
UIAccessibility.post(notification: .announcement, argument: progressLabel.text)
if let key = progress.key {
completing = true
scanner.stopScanning()
confirmImport(key)
return
}
} catch {
progressLabel.text = "That QR code could not be added"
UIAccessibility.post(notification: .announcement, argument: progressLabel.text)
presentFailure(error.localizedDescription)
}
}
}
func dataScanner(
_ dataScanner: DataScannerViewController,
becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable
) {
presentFailure("Live scanning became unavailable. Try again when the camera is free.")
}
@objc private func appWillResignActive() {
shield.isHidden = false
scanner.stopScanning()
}
@objc private func appDidBecomeActive() {
updateCaptureShield()
}
private func updateCaptureShield() {
let captured = traitCollection.sceneCaptureState == .active
shield.isHidden = !captured
if captured || completing || view.window == nil {
scanner.stopScanning()
} else {
try? scanner.startScanning()
}
}
private func confirmImport(_ key: MobileKeyTransferKey) {
let privateKey = key.kind == .private
let warning = privateKey
? "Import this private key only if you trust the device that displayed it."
: "Import this public key?"
let alert = UIAlertController(
title: privateKey ? "Import Private GPG Key?" : "Import Public GPG Key?",
message: "\(warning)\n\n\(key.title)\n\(key.detail)",
preferredStyle: .alert
)
if privateKey && key.requiresPassphrase {
alert.addTextField { field in
field.placeholder = "GPG key passphrase"
field.isSecureTextEntry = true
field.textContentType = .password
field.clearButtonMode = .whileEditing
}
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
alert.addAction(UIAlertAction(title: "Import", style: .default) { [weak self, weak alert] _ in
self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: false)
})
if privateKey {
alert.addAction(UIAlertAction(title: "Import as Default", style: .default) {
[weak self, weak alert] _ in
self?.finishImport(passphrase: alert?.textFields?.first?.text, makeDefault: true)
})
}
present(alert, animated: true)
}
private func finishImport(passphrase: String?, makeDefault: Bool) {
do {
let outcome = try importer.import(passphrase: passphrase, makeDefault: makeDefault)
let alert = UIAlertController(
title: outcome.title,
message: outcome.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
guard self != nil else { return }
NotificationCenter.default.post(name: .ironStorageKeyMaterialDidChange, object: nil)
})
present(alert, animated: true)
} catch {
completing = false
presentFailure(error.localizedDescription) { [weak self] in
guard let self else { return }
try? scanner.startScanning()
}
}
}
private func presentFailure(_ detail: String, completion: (() -> Void)? = nil) {
guard presentedViewController == nil else { return }
let alert = UIAlertController(
title: "GPG Key Transfer Failed",
message: detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completion?() })
present(alert, animated: true)
}
}
@MainActor
private final class KeyExportListViewController: UITableViewController {
private let transfer: MobileKeyTransfer
private let keys: [MobileKeyTransferKey]
init(transfer: MobileKeyTransfer) {
self.transfer = transfer
keys = transfer.keys()
super.init(style: .insetGrouped)
title = "Export GPG Key"
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func viewDidLoad() {
super.viewDidLoad()
if keys.isEmpty {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "key.slash")
configuration.text = "No GPG Keys"
configuration.secondaryText = "Import a key before exporting one."
contentUnavailableConfiguration = configuration
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
keys.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let key = keys[indexPath.row]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: key.kind == .private ? "key.fill" : "key")
content.text = key.title
content.secondaryText = key.detail
content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let key = keys[indexPath.row]
let sheet = UIAlertController(title: key.title, message: key.detail, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "Export Public Key", style: .default) {
[weak self] _ in self?.export(key, kind: .public, passphrase: nil)
})
if key.kind == .private {
sheet.addAction(UIAlertAction(title: "Export Private Key…", style: .destructive) {
[weak self] _ in self?.confirmPrivateExport(key)
})
}
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let popover = sheet.popoverPresentationController {
popover.sourceView = tableView.cellForRow(at: indexPath)
popover.sourceRect = tableView.cellForRow(at: indexPath)?.bounds ?? .zero
}
present(sheet, animated: true)
}
private func confirmPrivateExport(_ key: MobileKeyTransferKey) {
let alert = UIAlertController(
title: "Display Private GPG Key?",
message: "Anyone who scans these QR codes can use this private key.\n\n\(key.title)\n\(key.detail)",
preferredStyle: .alert
)
if key.requiresPassphrase {
alert.addTextField { field in
field.placeholder = "GPG key passphrase"
field.isSecureTextEntry = true
field.textContentType = .password
field.clearButtonMode = .whileEditing
}
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Display", style: .destructive) {
[weak self, weak alert] _ in
self?.export(key, kind: .private, passphrase: alert?.textFields?.first?.text)
})
present(alert, animated: true)
}
private func export(
_ key: MobileKeyTransferKey,
kind: MobileKeyTransferKind,
passphrase: String?
) {
do {
let exported = try transfer.export(
fingerprint: key.fingerprint,
kind: kind,
passphrase: passphrase
)
navigationController?.pushViewController(
KeyQrExportViewController(exported: exported),
animated: true
)
} catch {
let alert = UIAlertController(
title: "GPG Key Could Not Be Exported",
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
}
@MainActor
private final class KeyQrExportViewController: UIViewController {
private let exported: MobileKeyTransferExport
private let qrView = KeyQrView()
private let pageControl = UIPageControl()
private let progressLabel = UILabel()
private let shield = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
private var index = 0
init(exported: MobileKeyTransferExport) {
self.exported = exported
super.init(nibName: nil, bundle: nil)
title = exported.key.kind == .private ? "Private GPG Key" : "Public GPG Key"
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
qrView.translatesAutoresizingMaskIntoConstraints = false
qrView.layer.cornerRadius = 16
qrView.clipsToBounds = true
let identity = UILabel()
identity.text = exported.key.title
identity.font = .preferredFont(forTextStyle: .headline)
identity.adjustsFontForContentSizeCategory = true
identity.numberOfLines = 0
identity.textAlignment = .center
let fingerprint = UILabel()
fingerprint.text = exported.key.detail
fingerprint.font = .preferredFont(forTextStyle: .footnote)
fingerprint.adjustsFontForContentSizeCategory = true
fingerprint.numberOfLines = 0
fingerprint.textAlignment = .center
fingerprint.textColor = .secondaryLabel
progressLabel.font = .preferredFont(forTextStyle: .callout)
progressLabel.adjustsFontForContentSizeCategory = true
progressLabel.textAlignment = .center
pageControl.numberOfPages = exported.frames.count
pageControl.addTarget(self, action: #selector(pageChanged), for: .valueChanged)
pageControl.isHidden = exported.frames.count == 1
let stack = UIStackView(arrangedSubviews: [identity, fingerprint, qrView, progressLabel, pageControl])
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 14
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
let shieldLabel = UILabel()
shieldLabel.text = "Private key hidden while IronStorage is inactive or the screen is captured"
shieldLabel.font = .preferredFont(forTextStyle: .headline)
shieldLabel.adjustsFontForContentSizeCategory = true
shieldLabel.numberOfLines = 0
shieldLabel.textAlignment = .center
shieldLabel.translatesAutoresizingMaskIntoConstraints = false
shield.contentView.addSubview(shieldLabel)
shield.translatesAutoresizingMaskIntoConstraints = false
shield.isHidden = true
shield.accessibilityViewIsModal = true
view.addSubview(shield)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24),
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
qrView.widthAnchor.constraint(equalTo: qrView.heightAnchor),
qrView.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -48),
shield.leadingAnchor.constraint(equalTo: view.leadingAnchor),
shield.trailingAnchor.constraint(equalTo: view.trailingAnchor),
shield.topAnchor.constraint(equalTo: view.topAnchor),
shield.bottomAnchor.constraint(equalTo: view.bottomAnchor),
shieldLabel.leadingAnchor.constraint(equalTo: shield.contentView.leadingAnchor, constant: 32),
shieldLabel.trailingAnchor.constraint(equalTo: shield.contentView.trailingAnchor, constant: -32),
shieldLabel.centerYAnchor.constraint(equalTo: shield.contentView.centerYAnchor),
])
let left = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft))
left.direction = .left
let right = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight))
right.direction = .right
qrView.addGestureRecognizer(left)
qrView.addGestureRecognizer(right)
qrView.isUserInteractionEnabled = true
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
registerForTraitChanges([UITraitSceneCaptureState.self]) {
(controller: KeyQrExportViewController, _: UITraitCollection) in
controller.updateCaptureShield()
}
showFrame(0)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateCaptureShield()
}
@objc private func pageChanged() {
showFrame(pageControl.currentPage)
}
@objc private func swipedLeft() {
showFrame(min(index + 1, exported.frames.count - 1))
}
@objc private func swipedRight() {
showFrame(max(index - 1, 0))
}
private func showFrame(_ index: Int) {
self.index = index
pageControl.currentPage = index
qrView.transferFrame = exported.frames[index]
progressLabel.text = exported.frames.count == 1
? "Scan this QR code"
: "QR code \(index + 1) of \(exported.frames.count)"
qrView.accessibilityLabel = progressLabel.text
UIAccessibility.post(notification: .pageScrolled, argument: progressLabel.text)
}
@objc private func appWillResignActive() {
if exported.key.kind == .private {
shield.isHidden = false
}
}
@objc private func appDidBecomeActive() {
updateCaptureShield()
}
private func updateCaptureShield() {
shield.isHidden = exported.key.kind != .private || traitCollection.sceneCaptureState != .active
}
}
private final class KeyQrView: UIView {
var transferFrame: MobileKeyTransferFrame? {
didSet { setNeedsDisplay() }
}
override func draw(_ rect: CGRect) {
UIColor.white.setFill()
UIRectFill(rect)
guard let transferFrame, transferFrame.width > 0 else { return }
let width = Int(transferFrame.width)
let padded = width + 8
let scale = floor(min(rect.width, rect.height) / CGFloat(padded))
guard scale >= 1 else { return }
let symbolSize = CGFloat(padded) * scale
let origin = CGPoint(
x: rect.midX - symbolSize / 2 + 4 * scale,
y: rect.midY - symbolSize / 2 + 4 * scale
)
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setFillColor(UIColor.black.cgColor)
context.interpolationQuality = .none
for y in 0..<width {
for x in 0..<width where transferFrame.modules[y * width + x] != 0 {
context.fill(CGRect(
x: origin.x + CGFloat(x) * scale,
y: origin.y + CGFloat(y) * scale,
width: scale,
height: scale
))
}
}
}
}
@MainActor
private final class TotpListViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.totp
private let authentication: MobileAuthentication?
private var shellPage: MobilePage
private var page: MobileTotpPage?
private var loadTask: Task<Void, Never>?
private var unlockTask: Task<Void, Never>?
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
self.shellPage = shellPage
self.authentication = authentication
super.init(style: .insetGrouped)
title = shellPage.title
navigationItem.largeTitleDisplayMode = .always
refreshControl = UIRefreshControl()
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
loadTask?.cancel()
unlockTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageAuthenticationDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageWatchSnapshotDidChange,
object: nil
)
refreshState()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshState()
}
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
page?.rows.count ?? 0
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
guard let page else { return nil }
let unavailable = page.unavailableEntries == 0
? ""
: " \(page.unavailableEntries) entries could not be inspected with the active key."
return page.watch.detail + unavailable
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let row = page?.rows[indexPath.row]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: "timer")
content.text = row?.title
content.secondaryText = row?.detail
content.secondaryTextProperties.numberOfLines = 2
if row?.sharedWithWatch == true {
content.secondaryText = [row?.detail, "Apple Watch selected"]
.compactMap { $0 }
.joined(separator: "")
}
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
cell.accessibilityLabel = [row?.title, row?.detail].compactMap { $0 }.joined(separator: ", ")
cell.accessibilityValue = row?.sharedWithWatch == true ? "Selected for Apple Watch" : nil
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let row = page?.rows[indexPath.row], let authentication else { return }
loadTask?.cancel()
loadTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try authentication.touchUserActivity()
return Result<MobileTotpDetail, AuthenticationFailure>.success(
try authentication.totpDetail(
path: row.path,
unixSeconds: currentUnixSeconds()
)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case let .success(detail):
navigationController?.pushViewController(
TotpDetailViewController(authentication: authentication, detail: detail),
animated: true
)
case let .failure(failure):
handle(failure)
}
}
}
@objc private func refreshRequested() {
refreshState(force: true)
}
@objc private func authenticationDidChange() {
refreshState()
}
@objc private func localStoreDidChange() {
guard (try? authentication?.state().unlocked) == true else { return }
loadPage()
}
@objc private func unlockRequested() {
unlock(passphrase: nil)
}
@objc private func lockRequested() {
guard let authentication else { return }
do {
try authentication.manualLock()
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
} catch let error as MobileAuthenticationFfiError {
presentAuthenticationFailure(AuthenticationFailure(error))
} catch {
presentAuthenticationFailure(.unexpected)
}
}
private func refreshState(force: Bool = false) {
guard shellPage.state == .ready else {
page = nil
showUnavailable(
title: shellPage.stateTitle,
detail: shellPage.stateDetail,
image: shellPage.systemImage
)
return
}
if (try? authentication?.state().unlocked) == true {
if page == nil || force { loadPage() }
} else {
loadTask?.cancel()
page = nil
tableView.reloadData()
navigationItem.rightBarButtonItem = nil
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "lock.fill")
configuration.text = "TOTP Is Locked"
configuration.secondaryText =
"Authenticate to scan password entries for time-based one-time passwords."
configuration.button = .filled()
configuration.button.title = "Unlock"
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
self?.unlockRequested()
}
contentUnavailableConfiguration = configuration
refreshControl?.endRefreshing()
}
}
private func unlock(passphrase: String?) {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
unlockTask?.cancel()
showLoading("Unlocking TOTP")
unlockTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
try authentication.unlockTotp(passphrase: passphrase)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case .success:
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
loadPage()
UIAccessibility.post(notification: .announcement, argument: "TOTP unlocked")
case let .failure(failure)
where passphrase == nil
&& (failure.kind == .passphraseRequired
|| failure.kind == .biometryUnavailable):
promptForPassphrase(message: failure.detail)
case let .failure(failure):
if failure.kind != .cancelled { handle(failure) }
}
}
}
private func promptForPassphrase(message: String) {
let alert = UIAlertController(
title: "GPG Key Passphrase",
message: message,
preferredStyle: .alert
)
alert.addTextField { field in
field.isSecureTextEntry = true
field.textContentType = .password
field.placeholder = "Passphrase"
field.returnKeyType = .go
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
self?.refreshState()
})
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return }
alert?.textFields?.first?.text = nil
self?.unlock(passphrase: value)
})
present(alert, animated: true)
}
private func loadPage() {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
loadTask?.cancel()
showLoading("Loading TOTP")
loadTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileTotpPage, AuthenticationFailure>.success(
try authentication.totpPage()
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
refreshControl?.endRefreshing()
switch result {
case let .success(page):
self.page = page
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "lock.fill"),
style: .plain,
target: self,
action: #selector(lockRequested)
)
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
tableView.reloadData()
if page.rows.isEmpty {
showUnavailable(
title: "No TOTP Codes",
detail: "No valid time-based OTP entries were found in the password store.",
image: "timer"
)
} else {
contentUnavailableConfiguration = nil
}
case let .failure(failure):
handle(failure)
}
}
}
private func handle(_ failure: AuthenticationFailure) {
if failure.kind == .expired {
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
refreshState()
} else {
showUnavailable(title: failure.title, detail: failure.detail, image: "exclamationmark.triangle")
}
presentAuthenticationFailure(failure)
}
private func showLoading(_ title: String) {
var configuration = UIContentUnavailableConfiguration.loading()
configuration.text = title
configuration.secondaryText = "Reading OTP metadata in secure storage."
contentUnavailableConfiguration = configuration
}
private func showUnavailable(title: String, detail: String, image: String) {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: image)
configuration.text = title
configuration.secondaryText = detail
contentUnavailableConfiguration = configuration
refreshControl?.endRefreshing()
}
}
@MainActor
private final class TotpDetailViewController: UITableViewController {
private let authentication: MobileAuthentication
private var detail: MobileTotpDetail
private let codeContainer = UIView()
private let issuerLabel = UILabel()
private let accountLabel = UILabel()
private let codeLabel = UILabel()
private let countdownLabel = UILabel()
private let progress = UIProgressView(progressViewStyle: .default)
private let watchSwitch = UISwitch()
private var timerTask: Task<Void, Never>?
private var loadTask: Task<Void, Never>?
private var clipboardTask: Task<Void, Never>?
private var copiedValue: String?
init(authentication: MobileAuthentication, detail: MobileTotpDetail) {
self.authentication = authentication
self.detail = detail
super.init(style: .insetGrouped)
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
timerTask?.cancel()
loadTask?.cancel()
clipboardTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
configureHeader()
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "doc.on.doc"),
style: .plain,
target: self,
action: #selector(copyRequested)
)
navigationItem.rightBarButtonItem?.accessibilityLabel = "Copy current TOTP code"
watchSwitch.addTarget(self, action: #selector(watchSwitchChanged(_:)), for: .valueChanged)
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageAuthenticationDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(entryDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
apply(detail)
startTimer()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard let header = tableView.tableHeaderView else { return }
let height = header.systemLayoutSizeFitting(
CGSize(width: tableView.bounds.width, height: 0),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
).height
guard abs(header.frame.height - height) > 0.5 else { return }
header.frame.size.height = height
tableView.tableHeaderView = header
}
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
"Apple Watch"
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
detail.watch.detail
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.text = "Share with Apple Watch"
content.secondaryText = "Time-based codes only"
cell.contentConfiguration = content
cell.accessoryView = watchSwitch
cell.selectionStyle = .none
return cell
}
@objc private func authenticationDidChange() {
guard (try? authentication.state().unlocked) == true else {
lockDetail()
return
}
}
@objc private func entryDidChange() {
refreshCode()
}
@objc private func copyRequested() {
loadTask?.cancel()
let authentication = authentication
let path = detail.path
loadTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try authentication.touchUserActivity()
return Result<MobileEntryCopy, AuthenticationFailure>.success(
try authentication.copyTotpCode(
path: path,
unixSeconds: currentUnixSeconds()
)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
loadTask = nil
switch result {
case let .success(copy):
UIPasteboard.general.string = copy.value
copiedValue = copy.value
flashCopied()
clipboardTask?.cancel()
clipboardTask = Task { @MainActor in
do {
try await Task.sleep(for: .seconds(copy.timeoutSeconds))
} catch {
return
}
if UIPasteboard.general.string == copy.value {
UIPasteboard.general.items = []
}
self.copiedValue = nil
}
case let .failure(failure):
handle(failure)
}
}
}
@objc private func watchSwitchChanged(_ sender: UISwitch) {
let requested = sender.isOn
sender.isEnabled = false
loadTask?.cancel()
let authentication = authentication
let path = detail.path
loadTask = Task { [weak self, weak sender] in
let result = await Task.detached(priority: .userInitiated) {
do {
try authentication.touchUserActivity()
return Result<MobileTotpDetail, AuthenticationFailure>.success(
try authentication.setTotpWatchShared(
path: path,
shared: requested,
unixSeconds: currentUnixSeconds()
)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
loadTask = nil
sender?.isEnabled = true
switch result {
case let .success(detail):
apply(detail)
UINotificationFeedbackGenerator().notificationOccurred(.success)
NotificationCenter.default.post(
name: .ironStorageWatchSnapshotDidChange,
object: nil
)
case let .failure(failure):
sender?.setOn(!requested, animated: true)
handle(failure)
}
}
}
private func configureHeader() {
issuerLabel.font = .preferredFont(forTextStyle: .title2)
issuerLabel.adjustsFontForContentSizeCategory = true
issuerLabel.textAlignment = .center
accountLabel.font = .preferredFont(forTextStyle: .body)
accountLabel.textColor = .secondaryLabel
accountLabel.adjustsFontForContentSizeCategory = true
accountLabel.textAlignment = .center
accountLabel.numberOfLines = 0
codeLabel.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(
for: .monospacedDigitSystemFont(ofSize: 48, weight: .semibold)
)
codeLabel.adjustsFontForContentSizeCategory = true
codeLabel.textAlignment = .center
codeLabel.minimumScaleFactor = 0.55
codeLabel.adjustsFontSizeToFitWidth = true
codeLabel.layer.cornerRadius = 12
codeLabel.layer.masksToBounds = true
codeLabel.isAccessibilityElement = true
codeLabel.isUserInteractionEnabled = true
codeLabel.accessibilityTraits.insert(.button)
codeLabel.accessibilityHint = "Copies the current code"
countdownLabel.font = .preferredFont(forTextStyle: .footnote)
countdownLabel.textColor = .secondaryLabel
countdownLabel.adjustsFontForContentSizeCategory = true
countdownLabel.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [issuerLabel, accountLabel, codeLabel, progress, countdownLabel])
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
codeContainer.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.trailingAnchor),
stack.topAnchor.constraint(equalTo: codeContainer.topAnchor, constant: 20),
stack.bottomAnchor.constraint(equalTo: codeContainer.bottomAnchor, constant: -20),
])
codeContainer.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1)
tableView.tableHeaderView = codeContainer
codeLabel.addGestureRecognizer(
UITapGestureRecognizer(target: self, action: #selector(copyRequested))
)
}
private func apply(_ detail: MobileTotpDetail) {
self.detail = detail
title = detail.issuer ?? detail.account
issuerLabel.text = detail.issuer ?? "TOTP"
accountLabel.text = detail.account
codeLabel.text = groupedCode(detail.code)
codeLabel.accessibilityLabel = "Current code \(detail.code.map(String.init).joined(separator: " "))"
watchSwitch.setOn(detail.sharedWithWatch, animated: false)
tableView.reloadData()
updateCountdown()
}
private func startTimer() {
timerTask?.cancel()
timerTask = Task { [weak self] in
while !Task.isCancelled {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
guard let self else { return }
if currentUnixSeconds() >= detail.validUntil {
refreshCode()
} else {
updateCountdown()
}
}
}
}
private func updateCountdown() {
let remaining = detail.validUntil.saturatingSubtracting(currentUnixSeconds())
let fraction = detail.period == 0 ? 0 : Float(remaining) / Float(detail.period)
progress.setProgress(min(max(fraction, 0), 1), animated: true)
countdownLabel.text = "\(remaining) seconds remaining"
progress.accessibilityLabel = "Code validity"
progress.accessibilityValue = countdownLabel.text
}
private func refreshCode() {
guard loadTask == nil else { return }
let authentication = authentication
let path = detail.path
loadTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileTotpDetail, AuthenticationFailure>.success(
try authentication.totpDetail(
path: path,
unixSeconds: currentUnixSeconds()
)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard let self else { return }
loadTask = nil
guard !Task.isCancelled else { return }
switch result {
case let .success(detail): apply(detail)
case let .failure(failure): showDetailUnavailable(failure)
}
}
}
private func flashCopied() {
UINotificationFeedbackGenerator().notificationOccurred(.success)
UIAccessibility.post(notification: .announcement, argument: "TOTP code copied")
UIView.animate(withDuration: 0.12, animations: {
self.codeLabel.backgroundColor = .systemGreen.withAlphaComponent(0.28)
}) { _ in
UIView.animate(withDuration: 0.55) { self.codeLabel.backgroundColor = .clear }
}
}
private func handle(_ failure: AuthenticationFailure) {
if failure.kind == .expired {
lockDetail()
}
presentAuthenticationFailure(failure)
}
private func showDetailUnavailable(_ failure: AuthenticationFailure) {
if failure.kind == .expired {
lockDetail()
} else {
timerTask?.cancel()
detail.code = ""
codeLabel.text = nil
navigationItem.rightBarButtonItem = nil
watchSwitch.isEnabled = false
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "exclamationmark.triangle")
configuration.text = failure.title
configuration.secondaryText = failure.detail
configuration.button = .plain()
configuration.button.title = "Back to TOTP"
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
}
contentUnavailableConfiguration = configuration
}
presentAuthenticationFailure(failure)
}
private func lockDetail() {
timerTask?.cancel()
loadTask?.cancel()
clipboardTask?.cancel()
detail.code = ""
codeLabel.text = nil
if UIPasteboard.general.string == copiedValue {
UIPasteboard.general.items = []
}
copiedValue = nil
navigationItem.rightBarButtonItem = nil
watchSwitch.isEnabled = false
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "lock.fill")
configuration.text = "TOTP Is Locked"
configuration.secondaryText = "The code was removed when IronStorage locked."
configuration.button = .plain()
configuration.button.title = "Back to TOTP"
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
}
contentUnavailableConfiguration = configuration
UIAccessibility.post(notification: .announcement, argument: "TOTP locked")
}
}
private func currentUnixSeconds() -> UInt64 {
UInt64(max(Date().timeIntervalSince1970, 0))
}
private func groupedCode(_ code: String) -> String {
let midpoint = code.index(code.startIndex, offsetBy: code.count / 2)
return String(code[..<midpoint]) + " " + String(code[midpoint...])
}
private extension UInt64 {
func saturatingSubtracting(_ value: UInt64) -> UInt64 {
self > value ? self - value : 0
}
}
@MainActor
private final class PasswordMutationCoordinator {
private weak var presenter: UIViewController?
private let authentication: MobileAuthentication?
private var operationTask: Task<Void, Never>?
private var working = false
private var contextualCompletion: ((Bool) -> Void)?
init(presenter: UIViewController, authentication: MobileAuthentication?) {
self.presenter = presenter
self.authentication = authentication
}
deinit {
operationTask?.cancel()
}
func accessoryView(for row: MobilePasswordRow) -> UIView {
let button = UIButton(type: .system)
button.frame = CGRect(x: 0, y: 0, width: 44, height: 44)
button.configuration = .plain()
button.configuration?.image = UIImage(systemName: "ellipsis.circle")
button.configuration?.buttonSize = .medium
button.menu = menu(for: row)
button.showsMenuAsPrimaryAction = true
button.accessibilityLabel = "Actions for \(row.title)"
return button
}
func accessibilityActions(for row: MobilePasswordRow) -> [UIAccessibilityCustomAction] {
[
accessibilityAction("Move", image: "folder", action: .move, row: row),
accessibilityAction("Copy", image: "doc.on.doc", action: .copy, row: row),
accessibilityAction("Delete", image: "trash", action: .delete, row: row),
]
}
func swipeConfiguration(for row: MobilePasswordRow) -> UISwipeActionsConfiguration {
let delete = UIContextualAction(style: .destructive, title: "Delete") {
[weak self] _, _, completion in
self?.begin(.delete, row: row, completion: completion)
}
delete.image = UIImage(systemName: "trash")
let copy = UIContextualAction(style: .normal, title: "Copy") {
[weak self] _, _, completion in
self?.begin(.copy, row: row, completion: completion)
}
copy.image = UIImage(systemName: "doc.on.doc")
copy.backgroundColor = .systemBlue
let move = UIContextualAction(style: .normal, title: "Move") {
[weak self] _, _, completion in
self?.begin(.move, row: row, completion: completion)
}
move.image = UIImage(systemName: "folder")
move.backgroundColor = .systemOrange
let configuration = UISwipeActionsConfiguration(actions: [delete, copy, move])
configuration.performsFirstActionWithFullSwipe = true
return configuration
}
private func menu(for row: MobilePasswordRow) -> UIMenu {
UIMenu(children: [
UIAction(title: "Move", image: UIImage(systemName: "folder")) { [weak self] _ in
self?.begin(.move, row: row)
},
UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in
self?.begin(.copy, row: row)
},
UIAction(
title: "Delete",
image: UIImage(systemName: "trash"),
attributes: .destructive
) { [weak self] _ in
self?.begin(.delete, row: row)
},
])
}
private func accessibilityAction(
_ title: String,
image: String,
action: MobileMutationAction,
row: MobilePasswordRow
) -> UIAccessibilityCustomAction {
UIAccessibilityCustomAction(
name: title,
image: UIImage(systemName: image)
) { [weak self] _ in
self?.begin(action, row: row)
return true
}
}
private func begin(
_ action: MobileMutationAction,
row: MobilePasswordRow,
completion: ((Bool) -> Void)? = nil
) {
guard !working else {
completion?(false)
return
}
guard let authentication else {
completion?(false)
presenter?.presentAuthenticationFailure(.unavailable)
return
}
working = true
contextualCompletion = completion
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileMutationPlan, AuthenticationFailure>.success(
try authentication.prepareEntryMutation(path: row.path, action: action)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
operationTask = nil
switch result {
case let .success(plan):
present(plan)
case let .failure(failure):
fail(failure)
}
}
}
private func present(_ plan: MobileMutationPlan) {
switch plan.action {
case .delete:
confirmDelete(plan)
case .move, .copy:
guard !plan.destinations.isEmpty else {
fail(AuthenticationFailure(
kind: .entry,
title: "No Destination Available",
detail: "Create another password folder before moving or copying this entry."
))
return
}
let destinations = PasswordDestinationViewController(
plan: plan,
selected: { [weak self] destination in
self?.presenter?.dismiss(animated: true) {
self?.destinationSelected(destination, plan: plan)
}
},
cancelled: { [weak self] in
self?.presenter?.dismiss(animated: true) { self?.finish(false) }
}
)
let navigation = UINavigationController(rootViewController: destinations)
navigation.modalPresentationStyle = .formSheet
presenter?.present(navigation, animated: true)
}
}
private func destinationSelected(
_ destination: MobileMutationDestination,
plan: MobileMutationPlan
) {
if destination.requiresOverwrite {
let verb = plan.action == .move ? "Move" : "Copy"
let alert = UIAlertController(
title: "Replace Existing Password?",
message: "\(destination.title) already contains “\(plan.sourceTitle)”. \(verb) and replace it?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(title: "Replace", style: .destructive) {
[weak self] _ in
self?.confirmEditorIfNeeded(plan, destination: destination.path, overwrite: true)
})
presenter?.present(alert, animated: true)
} else {
confirmEditorIfNeeded(plan, destination: destination.path, overwrite: false)
}
}
private func confirmDelete(_ plan: MobileMutationPlan) {
let suffix = plan.hasDirtyEditor
? " Unsaved changes in its open editor will also be discarded."
: " This cant be undone."
let alert = UIAlertController(
title: "Delete “\(plan.sourceTitle)”?",
message: "The encrypted password entry will be permanently deleted.\(suffix)",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) {
[weak self] _ in
self?.perform(
plan,
destination: nil,
overwrite: false,
discardEditor: plan.hasOpenEditor,
mayAuthenticate: true
)
})
presenter?.present(alert, animated: true)
}
private func confirmEditorIfNeeded(
_ plan: MobileMutationPlan,
destination: String,
overwrite: Bool
) {
guard plan.hasOpenEditor else {
perform(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: false,
mayAuthenticate: true
)
return
}
let verb = plan.action == .move ? "Move" : "Copy"
let alert = UIAlertController(
title: plan.hasDirtyEditor ? "Discard Changes and \(verb)?" : "Close Editor and \(verb)?",
message: plan.hasDirtyEditor
? "Unsaved changes in the open editor for “\(plan.sourceTitle)” will be discarded."
: "The open editor for “\(plan.sourceTitle)” must close before this action.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(
title: plan.hasDirtyEditor ? "Discard and \(verb)" : "Close and \(verb)",
style: plan.hasDirtyEditor ? .destructive : .default
) { [weak self] _ in
self?.perform(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: true,
mayAuthenticate: true
)
})
presenter?.present(alert, animated: true)
}
private func perform(
_ plan: MobileMutationPlan,
destination: String?,
overwrite: Bool,
discardEditor: Bool,
mayAuthenticate: Bool
) {
guard let authentication else {
fail(.unavailable)
return
}
let request = MobileMutationRequest(
action: plan.action,
source: plan.source,
revision: plan.revision,
destination: destination,
confirmed: plan.action == .delete,
overwrite: overwrite,
discardEditor: discardEditor
)
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileMutationOutcome, AuthenticationFailure>.success(
try authentication.performEntryMutation(request: request)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
operationTask = nil
switch result {
case let .success(outcome):
UINotificationFeedbackGenerator().notificationOccurred(.success)
UIAccessibility.post(notification: .announcement, argument: outcome.detail)
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
finish(true)
case let .failure(failure)
where mayAuthenticate && failure.kind == .expired:
unlockAndRetry(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
passphrase: nil
)
case let .failure(failure):
fail(failure)
}
}
}
private func unlockAndRetry(
_ plan: MobileMutationPlan,
destination: String?,
overwrite: Bool,
discardEditor: Bool,
passphrase: String?
) {
guard let authentication else {
fail(.unavailable)
return
}
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
_ = try authentication.unlockEntry(path: plan.source, passphrase: passphrase)
return Result<Void, AuthenticationFailure>.success(())
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
operationTask = nil
switch result {
case .success:
perform(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
mayAuthenticate: false
)
case let .failure(failure)
where passphrase == nil
&& (failure.kind == .passphraseRequired
|| failure.kind == .biometryUnavailable):
promptForPassphrase(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
message: failure.detail
)
case let .failure(failure):
fail(failure)
}
}
}
private func promptForPassphrase(
_ plan: MobileMutationPlan,
destination: String?,
overwrite: Bool,
discardEditor: Bool,
message: String
) {
let alert = UIAlertController(
title: "GPG Key Passphrase",
message: message,
preferredStyle: .alert
)
alert.addTextField { field in
field.isSecureTextEntry = true
field.textContentType = .password
field.placeholder = "Passphrase"
field.returnKeyType = .go
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(title: "Unlock", style: .default) {
[weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text, !value.isEmpty else {
self?.finish(false)
return
}
alert?.textFields?.first?.text = nil
self?.unlockAndRetry(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
passphrase: value
)
})
presenter?.present(alert, animated: true)
}
private func fail(_ failure: AuthenticationFailure) {
presenter?.presentAuthenticationFailure(failure)
finish(false)
}
private func finish(_ applied: Bool) {
contextualCompletion?(applied)
contextualCompletion = nil
working = false
}
}
@MainActor
private final class PasswordDestinationViewController: UITableViewController {
private let plan: MobileMutationPlan
private let selected: (MobileMutationDestination) -> Void
private let cancelled: () -> Void
init(
plan: MobileMutationPlan,
selected: @escaping (MobileMutationDestination) -> Void,
cancelled: @escaping () -> Void
) {
self.plan = plan
self.selected = selected
self.cancelled = cancelled
super.init(style: .insetGrouped)
title = plan.action == .move ? "Move to Folder" : "Copy to Folder"
navigationItem.largeTitleDisplayMode = .never
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.cancelled() }
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
plan.destinations.count
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
plan.sourceTitle
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let destination = plan.destinations[indexPath.row]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: "folder")
content.text = destination.title
content.secondaryText = destination.requiresOverwrite
? "\(destination.detail) · Replaces existing password"
: destination.detail
content.secondaryTextProperties.numberOfLines = 2
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
cell.accessibilityHint = destination.requiresOverwrite
? "Requires confirmation before replacing the existing password."
: "Selects this destination folder."
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
selected(plan.destinations[indexPath.row])
}
}
@MainActor
private final class PasswordSearchViewController: UITableViewController, MobileTabRoot,
UISearchResultsUpdating
{
fileprivate let shellTab = MobileTab.search
private let authentication: MobileAuthentication?
private let searchController = UISearchController(searchResultsController: nil)
private var shellPage: MobilePage
private var searchPage: MobilePasswordPage?
private var searchTask: Task<Void, Never>?
private var shellTask: Task<Void, Never>?
private var generation = 0
private lazy var mutationCoordinator = PasswordMutationCoordinator(
presenter: self,
authentication: authentication
)
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
self.shellPage = shellPage
self.authentication = authentication
super.init(style: .insetGrouped)
title = shellPage.title
navigationItem.largeTitleDisplayMode = .always
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.placeholder = "Entry names and folders"
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.autocorrectionType = .no
searchController.searchBar.spellCheckingType = .no
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
definesPresentationContext = true
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
searchTask?.cancel()
shellTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
apply(shellPage)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadShell()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if searchController.searchBar.text?.isEmpty != false {
searchController.isActive = true
searchController.searchBar.becomeFirstResponder()
}
}
override func numberOfSections(in tableView: UITableView) -> Int {
searchPage?.rows.isEmpty == false ? 1 : 0
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
searchPage?.rows.count ?? 0
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
guard let row = searchPage?.rows[indexPath.row] else { return cell }
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: row.systemImage)
content.text = row.title
content.secondaryText = row.detail
content.textProperties.numberOfLines = 2
content.secondaryTextProperties.numberOfLines = 2
cell.contentConfiguration = content
cell.accessoryView = mutationCoordinator.accessoryView(for: row)
cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row)
cell.accessibilityLabel = "\(row.title), in \(row.detail)"
cell.accessibilityHint = "Opens the locked password viewer. More actions follow."
return cell
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
guard let rows = searchPage?.rows, rows.indices.contains(indexPath.row) else { return nil }
let row = rows[indexPath.row]
return mutationCoordinator.swipeConfiguration(for: row)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let row = searchPage?.rows[indexPath.row] else { return }
navigationController?.pushViewController(
LockedPasswordViewController(entry: row, authentication: authentication),
animated: true
)
}
func updateSearchResults(for searchController: UISearchController) {
search(searchController.searchBar.text ?? "")
}
@objc private func localStoreDidChange() {
guard viewIfLoaded?.window != nil else { return }
search(searchController.searchBar.text ?? "")
}
private func reloadShell() {
shellTask?.cancel()
shellTask = Task { [weak self] in
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
guard
!Task.isCancelled,
let self,
let page = shell.pages.first(where: { $0.tab == .search })
else { return }
apply(page)
}
}
private func apply(_ page: MobilePage) {
shellPage = page
title = page.title
guard page.state == .ready else {
searchTask?.cancel()
searchPage = nil
tableView.reloadData()
var configuration = page.state == .loading
? UIContentUnavailableConfiguration.loading()
: UIContentUnavailableConfiguration.empty()
configuration.image = page.state == .loading
? nil
: UIImage(systemName: page.state == .error ? "exclamationmark.triangle" : "lock.shield")
configuration.text = page.stateTitle
configuration.secondaryText = page.stateDetail
contentUnavailableConfiguration = configuration
return
}
search(searchController.searchBar.text ?? "")
}
private func search(_ query: String) {
generation += 1
let currentGeneration = generation
searchTask?.cancel()
guard shellPage.state == .ready else { return }
guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
searchPage = nil
tableView.reloadData()
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "magnifyingglass")
configuration.text = "Search Passwords"
configuration.secondaryText = "Search by entry name or folder."
contentUnavailableConfiguration = configuration
return
}
searchPage = nil
tableView.reloadData()
var loading = UIContentUnavailableConfiguration.loading()
loading.text = "Searching Passwords"
contentUnavailableConfiguration = loading
searchTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobilePasswordPage, PasswordFailure>.success(
try mobilePasswordSearch(query: query)
)
} catch let error as MobilePasswordFfiError {
return .failure(PasswordFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self, currentGeneration == generation else { return }
finish(result, query: query)
}
}
private func finish(
_ result: Result<MobilePasswordPage, PasswordFailure>,
query: String
) {
switch result {
case let .success(page):
searchPage = page
tableView.reloadData()
if page.rows.isEmpty {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "magnifyingglass")
configuration.text = "No Results"
configuration.secondaryText = "No password entries match “\(query)”."
contentUnavailableConfiguration = configuration
} else {
contentUnavailableConfiguration = nil
UIAccessibility.post(
notification: .announcement,
argument: "\(page.rows.count) password results"
)
}
case let .failure(failure):
searchPage = nil
tableView.reloadData()
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "exclamationmark.triangle")
configuration.text = failure.title
configuration.secondaryText = failure.detail
contentUnavailableConfiguration = configuration
}
}
}
@MainActor
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.passwords
private let path: String?
private let authentication: MobileAuthentication?
private var shellPage: MobilePage?
private var directoryPage: MobilePasswordPage?
private var loadTask: Task<Void, Never>?
private var createTask: Task<Void, Never>?
private var loadGeneration = 0
private lazy var mutationCoordinator = PasswordMutationCoordinator(
presenter: self,
authentication: authentication
)
init(
shellPage: MobilePage,
authentication: MobileAuthentication?,
path: String? = nil
) {
self.shellPage = shellPage
self.authentication = authentication
self.path = path
super.init(style: .insetGrouped)
title = shellPage.title
navigationItem.largeTitleDisplayMode = path == nil ? .always : .never
refreshControl = UIRefreshControl()
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
}
private init(path: String, title: String, authentication: MobileAuthentication?) {
self.path = path
self.authentication = authentication
shellPage = nil
super.init(style: .insetGrouped)
self.title = title
navigationItem.largeTitleDisplayMode = .never
refreshControl = UIRefreshControl()
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
loadTask?.cancel()
createTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
if let shellPage {
apply(shellPage)
} else {
showLoading()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reload()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadGeneration += 1
loadTask?.cancel()
refreshControl?.endRefreshing()
}
override func numberOfSections(in tableView: UITableView) -> Int {
directoryPage?.rows.isEmpty == false ? 1 : 0
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
directoryPage?.rows.count ?? 0
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
guard let row = directoryPage?.rows[indexPath.row] else { return cell }
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: row.systemImage)
content.text = row.title
content.secondaryText = row.detail
content.textProperties.numberOfLines = 2
content.secondaryTextProperties.numberOfLines = 1
cell.contentConfiguration = content
if row.kind == .entry {
cell.accessoryView = mutationCoordinator.accessoryView(for: row)
cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row)
} else {
cell.accessoryType = .disclosureIndicator
}
cell.accessibilityLabel = "\(row.title), \(row.detail)"
cell.accessibilityHint = row.kind == .directory
? "Opens this password folder."
: "Opens the locked password viewer. More actions follow."
return cell
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
guard let rows = directoryPage?.rows, rows.indices.contains(indexPath.row) else { return nil }
let row = rows[indexPath.row]
guard row.kind == .entry else { return nil }
return mutationCoordinator.swipeConfiguration(for: row)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let row = directoryPage?.rows[indexPath.row] else { return }
let controller: UIViewController = switch row.kind {
case .directory:
PasswordDirectoryViewController(
path: row.path,
title: row.title,
authentication: authentication
)
case .entry:
LockedPasswordViewController(entry: row, authentication: authentication)
}
navigationController?.pushViewController(controller, animated: true)
}
@objc private func refreshRequested() {
reload()
}
@objc private func localStoreDidChange() {
guard viewIfLoaded?.window != nil else { return }
reload()
}
@objc private func createRequested() {
let alert = UIAlertController(
title: "New Password",
message: "Enter a name for the password entry in this folder.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "Entry Name"
field.autocapitalizationType = .words
field.clearButtonMode = .whileEditing
field.returnKeyType = .next
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return }
self?.beginCreate(name: name, passphrase: nil)
})
present(alert, animated: true)
}
private func beginCreate(name: String, passphrase: String?) {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
createTask?.cancel()
let directory = path ?? ""
createTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileEntryEditorSession, AuthenticationFailure>.success(
try authentication.beginCreateEntry(
directory: directory,
name: name,
passphrase: passphrase
)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case let .success(session):
presentEditor(session, focusedField: nil)
case let .failure(failure)
where passphrase == nil
&& (failure.kind == .passphraseRequired
|| failure.kind == .biometryUnavailable):
promptForCreationPassphrase(name: name, message: failure.detail)
case let .failure(failure):
presentAuthenticationFailure(failure)
}
}
}
private func promptForCreationPassphrase(name: String, message: String) {
let alert = UIAlertController(
title: "GPG Key Passphrase",
message: message,
preferredStyle: .alert
)
alert.addTextField { field in
field.isSecureTextEntry = true
field.textContentType = .password
field.placeholder = "Passphrase"
field.returnKeyType = .go
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return }
alert?.textFields?.first?.text = nil
self?.beginCreate(name: name, passphrase: value)
})
present(alert, animated: true)
}
private func presentEditor(_ session: MobileEntryEditorSession, focusedField: UInt64?) {
guard let authentication else { return }
let editor = MobileEntryEditorViewController(
authentication: authentication,
session: session,
focusedField: focusedField
) { [weak self] _ in
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
self?.reload()
}
let navigation = UINavigationController(rootViewController: editor)
navigation.modalPresentationStyle = .formSheet
present(navigation, animated: true)
}
private func reload() {
loadGeneration += 1
let generation = loadGeneration
let selectedID = tableView.indexPathForSelectedRow.flatMap {
directoryPage?.rows[$0.row].id
}
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
if path == nil {
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
guard
!Task.isCancelled,
generation == loadGeneration,
let page = shell.pages.first(where: { $0.tab == .passwords })
else { return }
apply(page)
guard page.state == .ready else { return }
} else {
showLoading()
}
let path = self.path
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobilePasswordPage, PasswordFailure>.success(
try mobilePasswordPage(path: path)
)
} catch let error as MobilePasswordFfiError {
return .failure(PasswordFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, generation == loadGeneration else { return }
finish(result, selectedID: selectedID)
}
}
private func apply(_ page: MobilePage) {
shellPage = page
title = page.title
guard page.state == .ready else {
directoryPage = nil
navigationItem.rightBarButtonItem = nil
tableView.reloadData()
refreshControl?.endRefreshing()
var configuration = page.state == .loading
? UIContentUnavailableConfiguration.loading()
: UIContentUnavailableConfiguration.empty()
configuration.image = page.state == .loading
? nil
: UIImage(systemName: page.state == .error ? "exclamationmark.triangle" : "lock.shield")
configuration.text = page.stateTitle
configuration.secondaryText = page.stateDetail
contentUnavailableConfiguration = configuration
return
}
showLoading()
}
private func finish(
_ result: Result<MobilePasswordPage, PasswordFailure>,
selectedID: String?
) {
refreshControl?.endRefreshing()
switch result {
case let .success(page):
directoryPage = page
title = page.title
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(createRequested)
)
navigationItem.rightBarButtonItem?.accessibilityLabel = "Create password entry"
contentUnavailableConfiguration = nil
tableView.reloadData()
if page.rows.isEmpty {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "folder")
configuration.text = "Empty Folder"
configuration.secondaryText = "This password folder contains no entries."
contentUnavailableConfiguration = configuration
} else if let selectedID,
let row = page.rows.firstIndex(where: { $0.id == selectedID }) {
tableView.selectRow(at: IndexPath(row: row, section: 0), animated: false, scrollPosition: .none)
}
case let .failure(failure):
navigationItem.rightBarButtonItem = nil
if failure.kind == .directoryMissing, path != nil {
UIAccessibility.post(
notification: .announcement,
argument: "The password folder no longer exists."
)
navigationController?.popViewController(animated: true)
return
}
directoryPage = nil
tableView.reloadData()
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "exclamationmark.triangle")
configuration.text = failure.title
configuration.secondaryText = failure.detail
contentUnavailableConfiguration = configuration
}
}
private func showLoading() {
var configuration = UIContentUnavailableConfiguration.loading()
configuration.text = "Loading Passwords"
configuration.secondaryText = "Reading the locked folder model from storage."
contentUnavailableConfiguration = configuration
}
}
private struct PasswordFailure: Error, Sendable {
let kind: MobilePasswordErrorKind
let title: String
let detail: String
init(_ error: MobilePasswordFfiError) {
switch error {
case let .Failed(kind, title, detail):
self.kind = kind
self.title = title
self.detail = detail
}
}
static let unexpected = PasswordFailure(
kind: .repository,
title: "Passwords Are Unavailable",
detail: "IronStorage could not load the storage-provided folder."
)
private init(kind: MobilePasswordErrorKind, title: String, detail: String) {
self.kind = kind
self.title = title
self.detail = detail
}
}
@MainActor
private final class LockedPasswordViewController: UITableViewController {
private let entry: MobilePasswordRow
private let authentication: MobileAuthentication?
private var unlockTask: Task<Void, Never>?
private var entryTask: Task<Void, Never>?
private var clipboardTask: Task<Void, Never>?
private var feedbackTask: Task<Void, Never>?
private var state: MobileAuthenticationState?
private var page: MobileEntryPage?
private var revealedValues: [UInt64: String] = [:]
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
self.entry = entry
self.authentication = authentication
super.init(style: .insetGrouped)
title = entry.title
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(MobileEntryFieldCell.self, forCellReuseIdentifier: "EntryField")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 92
view.accessibilityIdentifier = entry.id
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageAuthenticationDidChange,
object: nil
)
refreshState()
}
deinit {
unlockTask?.cancel()
entryTask?.cancel()
clipboardTask?.cancel()
feedbackTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshState()
}
override func numberOfSections(in tableView: UITableView) -> Int {
page?.sections.count ?? 0
}
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
page?.sections[section].fields.count ?? 0
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
page?.sections[section].title
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
guard
let cell = tableView.dequeueReusableCell(
withIdentifier: "EntryField",
for: indexPath
) as? MobileEntryFieldCell,
let field = field(at: indexPath)
else { return UITableViewCell() }
cell.configure(
field: field,
revealedValue: revealedValues[field.id],
copy: { [weak self] tappedCell in self?.copy(field, in: tappedCell) },
reveal: { [weak self] in self?.revealOrHide(field) },
edit: { [weak self] in self?.edit(field) }
)
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard
let field = field(at: indexPath),
let cell = tableView.cellForRow(at: indexPath) as? MobileEntryFieldCell
else { return }
copy(field, in: cell)
}
@objc private func authenticationDidChange() {
refreshState()
}
@objc private func unlockRequested() {
unlock(passphrase: nil)
}
@objc private func lockRequested() {
guard let authentication else { return }
do {
try authentication.manualLock()
state = try authentication.state()
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
} catch let error as MobileAuthenticationFfiError {
presentAuthenticationFailure(AuthenticationFailure(error))
} catch {
presentAuthenticationFailure(.unexpected)
}
}
private func refreshState() {
state = try? authentication?.state()
if state?.unlocked == true {
if page == nil { loadEntry() }
} else {
maskAndDiscardEntry()
showLocked()
}
}
private func unlock(passphrase: String?) {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
unlockTask?.cancel()
showLoading(title: "Unlocking Password")
let path = entry.path
unlockTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
try authentication.unlockEntry(path: path, passphrase: passphrase)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case let .success(state):
self.state = state
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
loadEntry()
UIAccessibility.post(notification: .announcement, argument: "Password unlocked")
case let .failure(failure):
if passphrase == nil,
failure.kind == .passphraseRequired || failure.kind == .biometryUnavailable {
promptForPassphrase(message: failure.detail)
} else if failure.kind != .cancelled {
presentAuthenticationFailure(failure)
showLocked()
}
}
}
}
private func promptForPassphrase(message: String) {
let alert = UIAlertController(
title: "GPG Key Passphrase",
message: message,
preferredStyle: .alert
)
alert.addTextField { field in
field.isSecureTextEntry = true
field.textContentType = .password
field.placeholder = "Passphrase"
field.returnKeyType = .go
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
guard let field = alert?.textFields?.first, let value = field.text, !value.isEmpty else {
return
}
field.text = nil
self?.unlock(passphrase: value)
})
present(alert, animated: true)
}
private func loadEntry() {
guard let authentication else {
showLocked()
return
}
entryTask?.cancel()
showLoading(title: "Opening Password")
let path = entry.path
entryTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileEntryPage, AuthenticationFailure>.success(
try authentication.entryPage(path: path)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case let .success(page):
self.page = page
title = page.title
contentUnavailableConfiguration = nil
installLockButton()
tableView.reloadData()
case let .failure(failure):
handle(failure)
}
}
}
private func field(at indexPath: IndexPath) -> MobileEntryField? {
guard
let page,
page.sections.indices.contains(indexPath.section),
page.sections[indexPath.section].fields.indices.contains(indexPath.row)
else { return nil }
return page.sections[indexPath.section].fields[indexPath.row]
}
private func revealOrHide(_ field: MobileEntryField) {
if revealedValues.removeValue(forKey: field.id) != nil {
tableView.reloadData()
UIAccessibility.post(notification: .announcement, argument: "\(field.label) hidden")
return
}
performFieldAction(field) { authentication, path in
try authentication.revealEntryField(path: path, field: field.id)
} success: { [weak self] value in
self?.revealedValues[field.id] = value
self?.tableView.reloadData()
UIAccessibility.post(notification: .announcement, argument: "\(field.label) revealed")
}
}
private func copy(_ field: MobileEntryField, in cell: MobileEntryFieldCell?) {
cell?.flashCopied()
performFieldAction(field) { authentication, path in
try authentication.copyEntryField(path: path, field: field.id)
} success: { [weak self] copy in
guard let self else { return }
UIPasteboard.general.string = copy.value
showFeedback("\(field.label) copied")
UIAccessibility.post(notification: .announcement, argument: "\(field.label) copied")
clipboardTask?.cancel()
clipboardTask = Task { @MainActor in
do {
try await Task.sleep(for: .seconds(copy.timeoutSeconds))
} catch {
return
}
if UIPasteboard.general.string == copy.value {
UIPasteboard.general.items = []
}
}
}
}
private func edit(_ field: MobileEntryField) {
guard let authentication else { return }
entryTask?.cancel()
let path = entry.path
entryTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try authentication.touchUserActivity()
return Result<MobileEntryEditorSession, AuthenticationFailure>.success(
try authentication.beginEntryEditor(path: path)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case let .success(session):
let editor = MobileEntryEditorViewController(
authentication: authentication,
session: session,
focusedField: field.id
) { [weak self] _ in
NotificationCenter.default.post(
name: .ironStorageLocalStoreDidChange,
object: nil
)
self?.revealedValues.removeAll(keepingCapacity: false)
self?.loadEntry()
}
let navigation = UINavigationController(rootViewController: editor)
navigation.modalPresentationStyle = .formSheet
present(navigation, animated: true)
case let .failure(failure):
handle(failure)
}
}
}
private func performFieldAction<Value: Sendable>(
_ field: MobileEntryField,
operation: @escaping @Sendable (MobileAuthentication, String) throws -> Value,
success: @escaping @MainActor (Value) -> Void
) {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
let path = entry.path
Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try authentication.touchUserActivity()
return Result<Value, AuthenticationFailure>.success(
try operation(authentication, path)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case let .success(value): success(value)
case let .failure(failure): handle(failure)
}
}
}
private func handle(_ failure: AuthenticationFailure) {
if failure.kind == .expired {
state = try? authentication?.state()
maskAndDiscardEntry()
showLocked()
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
}
presentAuthenticationFailure(failure)
}
private func maskAndDiscardEntry() {
entryTask?.cancel()
feedbackTask?.cancel()
page = nil
revealedValues.removeAll(keepingCapacity: false)
tableView.reloadData()
navigationItem.rightBarButtonItem = nil
navigationItem.prompt = nil
title = entry.title
}
private func showLocked() {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "lock.fill")
configuration.text = "Locked Password"
configuration.secondaryText =
"Authenticate to decrypt this entry. Browsing remains available while locked."
configuration.button = .filled()
configuration.button.title = "Unlock"
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
self?.unlockRequested()
}
contentUnavailableConfiguration = configuration
}
private func showLoading(title: String) {
var configuration = UIContentUnavailableConfiguration.loading()
configuration.text = title
configuration.secondaryText = "Reading the structured entry from secure storage."
contentUnavailableConfiguration = configuration
}
private func showFeedback(_ message: String) {
feedbackTask?.cancel()
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "checkmark.circle.fill"),
style: .plain,
target: nil,
action: nil
)
navigationItem.rightBarButtonItem?.accessibilityLabel = message
UINotificationFeedbackGenerator().notificationOccurred(.success)
feedbackTask = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(2))
} catch {
return
}
guard let self else { return }
if page != nil { installLockButton() }
}
}
private func installLockButton() {
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "lock.fill"),
style: .plain,
target: self,
action: #selector(lockRequested)
)
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
}
}
@MainActor
private final class MobileEntryFieldCell: UITableViewCell {
private let iconView = UIImageView()
private let labelView = UILabel()
private let valueView = UITextView()
private let detailView = UILabel()
private let diagnosticView = UILabel()
private let revealButton = UIButton(type: .system)
private let editButton = UIButton(type: .system)
private var copyValue: ((MobileEntryFieldCell) -> Void)?
private var highlightTask: Task<Void, Never>?
private static let revealAction = UIAction.Identifier("reveal-entry-field")
private static let editAction = UIAction.Identifier("edit-entry-field")
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .default
iconView.tintColor = .secondaryLabel
iconView.setContentHuggingPriority(.required, for: .horizontal)
iconView.translatesAutoresizingMaskIntoConstraints = false
labelView.font = .preferredFont(forTextStyle: .caption1)
labelView.textColor = .secondaryLabel
labelView.adjustsFontForContentSizeCategory = true
valueView.font = .preferredFont(forTextStyle: .body)
valueView.adjustsFontForContentSizeCategory = true
valueView.backgroundColor = .clear
valueView.isEditable = false
valueView.isScrollEnabled = false
valueView.textContainerInset = .zero
valueView.textContainer.lineFragmentPadding = 0
let valueTap = UITapGestureRecognizer(target: self, action: #selector(valueTapped))
valueTap.cancelsTouchesInView = false
valueView.addGestureRecognizer(valueTap)
detailView.font = .preferredFont(forTextStyle: .footnote)
detailView.textColor = .secondaryLabel
detailView.adjustsFontForContentSizeCategory = true
detailView.numberOfLines = 0
diagnosticView.font = .preferredFont(forTextStyle: .footnote)
diagnosticView.textColor = .systemOrange
diagnosticView.adjustsFontForContentSizeCategory = true
diagnosticView.numberOfLines = 0
revealButton.configuration = .plain()
editButton.configuration = .plain()
editButton.configuration?.image = UIImage(systemName: "pencil")
editButton.accessibilityLabel = "Edit field"
let labels = UIStackView(arrangedSubviews: [labelView, valueView, detailView, diagnosticView])
labels.axis = .vertical
labels.spacing = 3
labels.setContentHuggingPriority(.defaultLow, for: .horizontal)
labels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let actions = UIStackView(arrangedSubviews: [revealButton, editButton])
actions.distribution = .fillEqually
NSLayoutConstraint.activate([
actions.widthAnchor.constraint(equalToConstant: 88),
actions.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
])
actions.setContentHuggingPriority(.required, for: .horizontal)
actions.setContentCompressionResistancePriority(.required, for: .horizontal)
let row = UIStackView(arrangedSubviews: [iconView, labels, actions])
row.alignment = .top
row.spacing = 12
row.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(row)
NSLayoutConstraint.activate([
iconView.widthAnchor.constraint(equalToConstant: 24),
iconView.heightAnchor.constraint(equalToConstant: 24),
row.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
row.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
row.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
row.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
func configure(
field: MobileEntryField,
revealedValue: String?,
copy: @escaping (MobileEntryFieldCell) -> Void,
reveal: @escaping () -> Void,
edit: @escaping () -> Void
) {
let value = revealedValue ?? field.value ?? field.maskedValue
iconView.image = UIImage(systemName: field.systemImage)
labelView.text = field.label
valueView.text = value
copyValue = copy
valueView.textColor = field.sensitive && revealedValue == nil ? .secondaryLabel : .label
valueView.isSelectable = field.selectable
detailView.text = field.detail
detailView.isHidden = field.detail == nil
diagnosticView.text = field.diagnostic
diagnosticView.isHidden = field.diagnostic == nil
revealButton.configuration?.image = UIImage(
systemName: revealedValue == nil ? "eye" : "eye.slash"
)
revealButton.accessibilityLabel = revealedValue == nil ? "Reveal field" : "Hide field"
revealButton.removeAction(identifiedBy: Self.revealAction, for: .touchUpInside)
revealButton.addAction(
UIAction(identifier: Self.revealAction) { _ in reveal() },
for: .touchUpInside
)
revealButton.alpha = field.sensitive ? 1 : 0
revealButton.isEnabled = field.sensitive
revealButton.accessibilityElementsHidden = !field.sensitive
editButton.removeAction(identifiedBy: Self.editAction, for: .touchUpInside)
editButton.addAction(
UIAction(identifier: Self.editAction) { _ in edit() },
for: .touchUpInside
)
editButton.alpha = field.editable ? 1 : 0
editButton.isEnabled = field.editable
editButton.accessibilityElementsHidden = !field.editable
accessibilityLabel = [field.label, value, field.detail, field.diagnostic]
.compactMap { $0 }
.joined(separator: ", ")
accessibilityHint = field.sensitive
? "Double tap the row to copy. Reveal and Edit buttons follow."
: "Double tap the row to copy. An Edit button follows."
}
func flashCopied() {
highlightTask?.cancel()
setHighlighted(true, animated: false)
highlightTask = Task { [weak self] in
do {
try await Task.sleep(for: .milliseconds(350))
} catch {
return
}
self?.setHighlighted(false, animated: true)
}
}
override func prepareForReuse() {
super.prepareForReuse()
highlightTask?.cancel()
setHighlighted(false, animated: false)
}
@objc private func valueTapped() {
copyValue?(self)
}
}
@MainActor
private final class MobileEntryEditorViewController: UITableViewController,
UIAdaptivePresentationControllerDelegate
{
private struct FieldState {
var name: String?
var value: String?
}
private let authentication: MobileAuthentication
private var editorID: UInt64
private var page: MobileEntryEditorPage
private let focusedField: UInt64?
private let saved: (String) -> Void
private var states: [UInt64: FieldState] = [:]
private var localDirty = false
private var focused = false
private var operationTask: Task<Void, Never>?
init(
authentication: MobileAuthentication,
session: MobileEntryEditorSession,
focusedField: UInt64?,
saved: @escaping (String) -> Void
) {
self.authentication = authentication
editorID = session.id
page = session.page
self.focusedField = focusedField
self.saved = saved
super.init(style: .insetGrouped)
resetStates()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
operationTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
title = page.creating ? "New \(page.title)" : "Edit \(page.title)"
navigationItem.largeTitleDisplayMode = .never
tableView.register(MobileEntryEditorCell.self, forCellReuseIdentifier: "EditorField")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.setEditing(true, animated: false)
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.cancelRequested() }
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
systemItem: .save,
primaryAction: UIAction { [weak self] _ in self?.saveRequested() }
)
let add = UIBarButtonItem(
systemItem: .add,
primaryAction: UIAction { [weak self] _ in self?.addRequested() }
)
add.accessibilityLabel = "Add entry field"
let generate = UIBarButtonItem(
title: "Generate Password",
image: UIImage(systemName: "wand.and.stars"),
primaryAction: UIAction { [weak self] _ in self?.generateRequested() }
)
toolbarItems = [
add,
UIBarButtonItem(systemItem: .flexibleSpace),
generate,
]
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageAuthenticationDidChange,
object: nil
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setToolbarHidden(false, animated: animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.presentationController?.delegate = self
guard !focused, let focusedField,
let index = page.fields.firstIndex(where: { $0.id == focusedField })
else { return }
focused = true
let path = IndexPath(row: index, section: 0)
tableView.scrollToRow(at: path, at: .middle, animated: true)
(tableView.cellForRow(at: path) as? MobileEntryEditorCell)?.focusValue()
}
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
page.fields.count
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
"Entry Fields"
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
"Drag the reorder controls to preserve the exact field order saved by Rust."
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: "EditorField",
for: indexPath
) as? MobileEntryEditorCell else { return UITableViewCell() }
let field = page.fields[indexPath.row]
let state = states[field.id] ?? FieldState(name: field.name, value: field.value)
cell.configure(field: field, name: state.name, value: state.value) { [weak self] name, value in
self?.states[field.id] = FieldState(name: name, value: value)
self?.localDirty = true
}
cell.showsReorderControl = field.reorderable
return cell
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
page.fields[indexPath.row].removable
}
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
page.fields[indexPath.row].reorderable
}
override func tableView(
_ tableView: UITableView,
editingStyleForRowAt indexPath: IndexPath
) -> UITableViewCell.EditingStyle {
page.fields[indexPath.row].removable ? .delete : .none
}
override func tableView(
_ tableView: UITableView,
commit editingStyle: UITableViewCell.EditingStyle,
forRowAt indexPath: IndexPath
) {
guard editingStyle == .delete else { return }
do {
try synchronizeDraft()
page = try authentication.removeEntryEditorField(
editor: editorID,
field: page.fields[indexPath.row].id
)
resetStates()
tableView.deleteRows(at: [indexPath], with: .automatic)
} catch {
presentEditorFailure(error)
tableView.reloadData()
}
}
override func tableView(
_ tableView: UITableView,
moveRowAt sourceIndexPath: IndexPath,
to destinationIndexPath: IndexPath
) {
let field = page.fields[sourceIndexPath.row]
do {
try synchronizeDraft()
page = try authentication.reorderEntryEditorField(
editor: editorID,
field: field.id,
index: UInt32(destinationIndexPath.row)
)
resetStates()
tableView.reloadData()
} catch {
presentEditorFailure(error)
tableView.reloadData()
}
}
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
guard isDirty else {
try? authentication.discardEntryEditor(editor: editorID)
return true
}
return false
}
func presentationControllerDidAttemptToDismiss(
_ presentationController: UIPresentationController
) {
confirmDiscard()
}
@objc private func authenticationDidChange() {
guard (try? authentication.state().unlocked) != true else { return }
states = states.mapValues { _ in FieldState(name: nil, value: nil) }
tableView.visibleCells
.compactMap { $0 as? MobileEntryEditorCell }
.forEach { $0.clearSensitiveContent() }
dismiss(animated: true)
UIAccessibility.post(notification: .announcement, argument: "Entry editor locked")
}
private var isDirty: Bool { localDirty || page.dirty }
private func resetStates() {
states = Dictionary(uniqueKeysWithValues: page.fields.map {
($0.id, FieldState(name: $0.name, value: $0.value))
})
localDirty = false
}
private func inputs() -> [MobileEntryEditorInput] {
page.fields.map { field in
let state = states[field.id] ?? FieldState(name: field.name, value: field.value)
return MobileEntryEditorInput(id: field.id, name: state.name, value: state.value)
}
}
private func synchronizeDraft() throws {
view.endEditing(true)
try authentication.touchUserActivity()
page = try authentication.updateEntryEditor(editor: editorID, fields: inputs())
resetStates()
}
private func cancelRequested() {
if isDirty {
confirmDiscard()
} else {
discardAndDismiss()
}
}
private func confirmDiscard() {
let sheet = UIAlertController(
title: "Discard Changes?",
message: "Unsaved entry changes will be lost.",
preferredStyle: .actionSheet
)
sheet.addAction(UIAlertAction(title: "Discard Changes", style: .destructive) {
[weak self] _ in self?.discardAndDismiss()
})
sheet.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
sheet.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem
present(sheet, animated: true)
}
private func discardAndDismiss() {
try? authentication.discardEntryEditor(editor: editorID)
states.removeAll(keepingCapacity: false)
dismiss(animated: true)
}
private func addRequested() {
let sheet = UIAlertController(title: "Add Field", message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "Named Field", style: .default) { [weak self] _ in
self?.promptForNamedField()
})
sheet.addAction(UIAlertAction(title: "Notes", style: .default) { [weak self] _ in
self?.addField(kind: .note, name: nil, value: "")
})
sheet.addAction(UIAlertAction(title: "OTP URI", style: .default) { [weak self] _ in
self?.promptForOtpUri()
})
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
sheet.popoverPresentationController?.barButtonItem = toolbarItems?.first
present(sheet, animated: true)
}
private func promptForNamedField() {
let alert = UIAlertController(
title: "Named Field",
message: "Enter a field name. You can enter its value in the form.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "Field Name"
field.autocapitalizationType = .none
field.autocorrectionType = .no
field.clearButtonMode = .whileEditing
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return }
self?.addField(kind: .field, name: name, value: "")
})
present(alert, animated: true)
}
private func promptForOtpUri() {
let alert = UIAlertController(
title: "OTP URI",
message: "Enter the complete otpauth URI.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "otpauth://…"
field.keyboardType = .URL
field.autocapitalizationType = .none
field.autocorrectionType = .no
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return }
self?.addField(kind: .otpUri, name: nil, value: value)
})
present(alert, animated: true)
}
private func addField(kind: MobileEntryEditorFieldKind, name: String?, value: String) {
do {
try synchronizeDraft()
page = try authentication.addEntryEditorField(
editor: editorID,
kind: kind,
name: name,
value: value
)
resetStates()
tableView.reloadData()
if !page.fields.isEmpty {
let path = IndexPath(row: page.fields.count - 1, section: 0)
tableView.scrollToRow(at: path, at: .bottom, animated: true)
}
} catch {
presentEditorFailure(error)
}
}
private func generateRequested() {
let alert = UIAlertController(
title: "Generate Password",
message: "Choose a validated length from 1 through \(page.maximumPasswordLength).",
preferredStyle: .alert
)
alert.addTextField { [defaultLength = page.defaultPasswordLength] field in
field.text = String(defaultLength)
field.keyboardType = .numberPad
field.clearButtonMode = .whileEditing
field.accessibilityLabel = "Password length"
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "With Symbols", style: .default) { [weak self, weak alert] _ in
self?.generate(from: alert, noSymbols: false)
})
alert.addAction(UIAlertAction(title: "Letters & Numbers", style: .default) {
[weak self, weak alert] _ in self?.generate(from: alert, noSymbols: true)
})
present(alert, animated: true)
}
private func generate(from alert: UIAlertController?, noSymbols: Bool) {
guard let text = alert?.textFields?.first?.text,
let length = UInt32(text)
else {
let failure = UIAlertController(
title: "Invalid Password Length",
message: "Enter a whole number from 1 through \(page.maximumPasswordLength).",
preferredStyle: .alert
)
failure.addAction(UIAlertAction(title: "OK", style: .default))
present(failure, animated: true)
return
}
do {
try synchronizeDraft()
page = try authentication.generateEntryEditorPassword(
editor: editorID,
length: length,
noSymbols: noSymbols
)
resetStates()
tableView.reloadData()
UIAccessibility.post(notification: .announcement, argument: "Password generated")
} catch {
presentEditorFailure(error)
}
}
private func saveRequested() {
view.endEditing(true)
let fields = inputs()
let authentication = authentication
let editorID = editorID
navigationItem.leftBarButtonItem?.isEnabled = false
navigationItem.rightBarButtonItem?.isEnabled = false
operationTask?.cancel()
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try authentication.touchUserActivity()
return Result<MobileEntryPage, AuthenticationFailure>.success(
try authentication.saveEntryEditor(editor: editorID, fields: fields)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
navigationItem.leftBarButtonItem?.isEnabled = true
navigationItem.rightBarButtonItem?.isEnabled = true
switch result {
case let .success(savedPage):
states.removeAll(keepingCapacity: false)
dismiss(animated: true) { self.saved(savedPage.id) }
case let .failure(failure):
presentSaveFailure(failure)
}
}
}
private func presentSaveFailure(_ failure: AuthenticationFailure) {
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
if failure.kind == .conflict {
alert.addAction(UIAlertAction(title: "Reload Latest", style: .destructive) {
[weak self] _ in self?.reloadLatest()
})
}
alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
present(alert, animated: true)
}
private func reloadLatest() {
let path = page.path
try? authentication.discardEntryEditor(editor: editorID)
do {
let session = try authentication.beginEntryEditor(path: path)
editorID = session.id
page = session.page
resetStates()
tableView.reloadData()
} catch {
presentEditorFailure(error)
}
}
private func presentEditorFailure(_ error: Error) {
let failure = (error as? MobileAuthenticationFfiError).map(AuthenticationFailure.init)
?? .unexpected
presentAuthenticationFailure(failure)
}
}
@MainActor
private final class MobileEntryEditorCell: UITableViewCell, UITextViewDelegate {
private let iconView = UIImageView()
private let labelView = UILabel()
private let nameField = UITextField()
private let valueField = UITextField()
private let valueView = UITextView()
private let diagnosticView = UILabel()
private let revealButton = UIButton(type: .system)
private var changed: ((String?, String?) -> Void)?
private var currentName: String?
private var currentValue: String?
private var sensitive = false
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
iconView.tintColor = .secondaryLabel
iconView.translatesAutoresizingMaskIntoConstraints = false
labelView.font = .preferredFont(forTextStyle: .caption1)
labelView.textColor = .secondaryLabel
labelView.adjustsFontForContentSizeCategory = true
for field in [nameField, valueField] {
field.font = .preferredFont(forTextStyle: .body)
field.adjustsFontForContentSizeCategory = true
field.clearButtonMode = .whileEditing
field.addTarget(self, action: #selector(textFieldChanged(_:)), for: .editingChanged)
}
nameField.placeholder = "Field Name"
nameField.autocapitalizationType = .none
nameField.autocorrectionType = .no
valueView.font = .preferredFont(forTextStyle: .body)
valueView.adjustsFontForContentSizeCategory = true
valueView.backgroundColor = .clear
valueView.isScrollEnabled = false
valueView.textContainerInset = .zero
valueView.textContainer.lineFragmentPadding = 0
valueView.delegate = self
diagnosticView.font = .preferredFont(forTextStyle: .footnote)
diagnosticView.textColor = .systemOrange
diagnosticView.adjustsFontForContentSizeCategory = true
diagnosticView.numberOfLines = 0
revealButton.configuration = .plain()
revealButton.configuration?.image = UIImage(systemName: "eye")
revealButton.addAction(UIAction { [weak self] _ in self?.toggleReveal() }, for: .touchUpInside)
let heading = UIStackView(arrangedSubviews: [labelView, nameField])
heading.axis = .vertical
heading.spacing = 2
let body = UIStackView(arrangedSubviews: [heading, valueField, valueView, diagnosticView])
body.axis = .vertical
body.spacing = 6
let row = UIStackView(arrangedSubviews: [iconView, body, revealButton])
row.alignment = .top
row.spacing = 12
row.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(row)
NSLayoutConstraint.activate([
iconView.widthAnchor.constraint(equalToConstant: 24),
iconView.heightAnchor.constraint(equalToConstant: 24),
revealButton.widthAnchor.constraint(equalToConstant: 44),
revealButton.heightAnchor.constraint(equalToConstant: 44),
valueView.heightAnchor.constraint(greaterThanOrEqualToConstant: 72),
row.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
row.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
row.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
row.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
func configure(
field: MobileEntryEditorField,
name: String?,
value: String?,
changed: @escaping (String?, String?) -> Void
) {
self.changed = changed
currentName = name
currentValue = value
sensitive = field.sensitive
iconView.image = UIImage(systemName: field.systemImage)
labelView.text = field.label
nameField.text = name
nameField.isHidden = !field.nameEditable
labelView.isHidden = field.nameEditable
let multiline = field.multiline || field.kind == .note
valueField.isHidden = multiline
valueView.isHidden = !multiline
valueField.text = value ?? field.maskedValue
valueView.text = value ?? field.maskedValue
let editable = value != nil
nameField.isEnabled = editable
valueField.isEnabled = editable
valueView.isEditable = editable
valueField.isSecureTextEntry = field.sensitive
valueField.textContentType = field.kind == .password ? .password : nil
valueField.keyboardType = field.kind == .otpUri ? .URL : .default
valueField.autocapitalizationType = field.sensitive ? .none : .sentences
valueField.autocorrectionType = field.sensitive ? .no : .default
valueView.autocapitalizationType = .sentences
diagnosticView.text = field.diagnostic
diagnosticView.isHidden = field.diagnostic == nil
revealButton.isHidden = !field.sensitive
revealButton.accessibilityLabel = "Reveal \(field.label)"
nameField.accessibilityLabel = "\(field.label) name"
valueField.accessibilityLabel = field.label
valueView.accessibilityLabel = field.label
}
func focusValue() {
if valueView.isHidden {
valueField.becomeFirstResponder()
} else {
valueView.becomeFirstResponder()
}
}
func clearSensitiveContent() {
currentValue = nil
valueField.text = nil
valueView.text = nil
}
override func prepareForReuse() {
super.prepareForReuse()
changed = nil
currentName = nil
currentValue = nil
valueField.isSecureTextEntry = false
}
@objc private func textFieldChanged(_ sender: UITextField) {
if sender === nameField {
currentName = sender.text
} else {
currentValue = sender.text
}
changed?(currentName, currentValue)
}
func textViewDidChange(_ textView: UITextView) {
currentValue = textView.text
changed?(currentName, currentValue)
}
private func toggleReveal() {
guard sensitive else { return }
valueField.isSecureTextEntry.toggle()
revealButton.configuration?.image = UIImage(
systemName: valueField.isSecureTextEntry ? "eye" : "eye.slash"
)
revealButton.accessibilityLabel = valueField.isSecureTextEntry
? "Reveal field"
: "Hide field"
}
}
private struct AuthenticationFailure: Error, Sendable {
let kind: MobileAuthenticationErrorKind
let title: String
let detail: String
init(_ error: MobileAuthenticationFfiError) {
switch error {
case let .Failed(kind, title, detail):
self.kind = kind
self.title = title
self.detail = detail
}
}
static let unexpected = AuthenticationFailure(
kind: .secureStorage,
title: "Unlock Failed",
detail: "IronStorage could not complete authentication."
)
static let unavailable = AuthenticationFailure(
kind: .configuration,
title: "Authentication Is Unavailable",
detail: "Finish password-store setup before unlocking entries."
)
fileprivate init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
self.kind = kind
self.title = title
self.detail = detail
}
}
@MainActor
private extension UIViewController {
func presentAuthenticationFailure(_ failure: AuthenticationFailure) {
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
private struct HomeFailure: Error, Sendable {
let kind: MobileHomeErrorKind
let title: String
let detail: String
init(_ error: MobileHomeFfiError) {
switch error {
case let .Failed(kind, title, detail):
self.kind = kind
self.title = title
self.detail = detail
}
}
static let unexpected = HomeFailure(
kind: .repository,
title: "Remote Activity Failed",
detail: "IronStorage could not load the storage-provided Home page."
)
private init(kind: MobileHomeErrorKind, title: String, detail: String) {
self.kind = kind
self.title = title
self.detail = detail
}
}
@MainActor
private final class MobileCommitActivityViewController: UITableViewController {
private let commit: MobileHomeCommit
init(commit: MobileHomeCommit, incoming: Bool) {
self.commit = commit
super.init(style: .insetGrouped)
title = incoming ? "Incoming Commit" : "Outgoing Commit"
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 1 : max(commit.changes.count, 1)
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
section == 0 ? "Commit" : "Password Store Changes"
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
if indexPath.section == 0 {
content.image = UIImage(systemName: commit.systemImage)
content.text = commit.title
content.secondaryText = "\(commit.detail)\n\(formattedDate)"
} else if commit.changes.isEmpty {
content.image = UIImage(systemName: "minus.circle")
content.text = "No File Changes"
content.secondaryText = "This commit does not change password-store files."
} else {
let change = commit.changes[indexPath.row]
content.image = UIImage(systemName: change.systemImage)
content.text = change.title
content.secondaryText = change.detail
}
content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content
cell.selectionStyle = .none
return cell
}
private var formattedDate: String {
Date(timeIntervalSince1970: TimeInterval(commit.timestamp)).formatted(
date: .abbreviated,
time: .shortened
)
}
}
@MainActor
private final class TokenUpdateViewController: UITableViewController {
private let accountField = UITextField()
private let tokenField = UITextField()
private var updateTask: Task<Void, Never>?
private var generation = 0
private var isWorking = false
init() {
super.init(style: .insetGrouped)
title = "Update Token"
navigationItem.largeTitleDisplayMode = .never
for field in [accountField, tokenField] {
field.borderStyle = .none
field.clearButtonMode = .whileEditing
field.autocorrectionType = .no
field.autocapitalizationType = .none
field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body)
}
accountField.placeholder = "Account name"
tokenField.placeholder = "New application token"
tokenField.textContentType = .oneTimeCode
tokenField.isSecureTextEntry = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
updateTask?.cancel()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
generation += 1
updateTask?.cancel()
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 2 : 1
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
section == 0 ? "HTTPS Credential" : nil
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
section == 0
? "The existing token is never displayed. Updating replaces it only in protected system storage."
: nil
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
if indexPath.section == 1 {
cell.textLabel?.text = isWorking ? "Updating…" : "Replace Token"
cell.textLabel?.textColor = isWorking ? .secondaryLabel : view.tintColor
cell.textLabel?.textAlignment = .center
cell.isUserInteractionEnabled = !isWorking
return cell
}
let field = indexPath.row == 0 ? accountField : tokenField
field.translatesAutoresizingMaskIntoConstraints = false
field.isEnabled = !isWorking
cell.contentView.addSubview(field)
NSLayoutConstraint.activate([
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10)
])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1, !isWorking else { return }
replaceToken()
}
private func replaceToken() {
generation += 1
let current = generation
isWorking = true
navigationItem.prompt = "Saving in protected system storage."
tableView.reloadData()
let account = accountField.text ?? ""
let token = tokenField.text ?? ""
updateTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try replaceConfiguredMobileApplicationToken(
account: account,
applicationToken: token
)
return Result<Void, OnboardingFailure>.success(())
} catch let error as MobileOnboardingFfiError {
return .failure(OnboardingFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard let self, current == generation else { return }
isWorking = false
navigationItem.prompt = nil
tableView.reloadData()
switch result {
case .success:
tokenField.text = nil
let alert = UIAlertController(
title: "Token Updated",
message: "The new application token is ready for HTTPS Git operations.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
present(alert, animated: true)
case let .failure(failure):
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
}
}
}
@MainActor
private final class OnboardingViewController: UITableViewController {
private let serverField = UITextField()
private let accountField = UITextField()
private let repositoryField = UITextField()
private let tokenField = UITextField()
private var branches: [String] = []
private var selectedBranch = 0
private var operation: MobileOnboardingOperation?
private var workTask: Task<Void, Never>?
private var progressTask: Task<Void, Never>?
private var generation = 0
private var isWorking = false
init() {
super.init(style: .insetGrouped)
title = "Connect Store"
navigationItem.largeTitleDisplayMode = .never
configureFields()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
operation?.cancel()
workTask?.cancel()
progressTask?.cancel()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
cancelWork()
}
override func numberOfSections(in tableView: UITableView) -> Int {
branches.isEmpty ? 2 : 3
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: 4
case 1: 1
default: 2
}
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
switch section {
case 0: "HTTPS Repository"
case 1: branches.isEmpty ? nil : "Remote"
default: "Local Clone"
}
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
section == 0 ? "The application token is stored in protected system storage, never in configuration." : nil
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
if indexPath.section == 0 {
return fieldCell(indexPath.row)
}
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
if branches.isEmpty {
cell.textLabel?.text = "Discover Branches"
cell.textLabel?.textColor = view.tintColor
cell.accessoryType = .disclosureIndicator
} else if indexPath.section == 1 {
cell.textLabel?.text = "Branch"
cell.detailTextLabel?.text = branches[selectedBranch]
cell.accessoryType = .disclosureIndicator
} else if indexPath.row == 0 {
cell.textLabel?.text = "Clone Password Store"
cell.textLabel?.textColor = view.tintColor
} else {
cell.textLabel?.text = "Use Existing Clone"
cell.textLabel?.textColor = .secondaryLabel
}
cell.isUserInteractionEnabled = !isWorking
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard !isWorking else { return }
if branches.isEmpty, indexPath.section == 1 {
discoverBranches()
} else if indexPath.section == 1 {
chooseBranch(from: tableView.cellForRow(at: indexPath))
} else if indexPath.section == 2 {
runSetup(useExisting: indexPath.row == 1)
}
}
private func configureFields() {
for field in [serverField, accountField, repositoryField, tokenField] {
field.borderStyle = .none
field.clearButtonMode = .whileEditing
field.autocorrectionType = .no
field.returnKeyType = .next
field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body)
}
serverField.placeholder = "https://git.example.com"
serverField.textContentType = .URL
serverField.keyboardType = .URL
serverField.autocapitalizationType = .none
accountField.placeholder = "Account name"
accountField.autocapitalizationType = .none
repositoryField.placeholder = "owner/password-store"
repositoryField.autocapitalizationType = .none
tokenField.placeholder = "Application token"
tokenField.textContentType = .oneTimeCode
tokenField.isSecureTextEntry = true
}
private func fieldCell(_ row: Int) -> UITableViewCell {
let field = [serverField, accountField, repositoryField, tokenField][row]
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
field.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(field)
NSLayoutConstraint.activate([
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10)
])
return cell
}
private func makeOperation() throws -> MobileOnboardingOperation {
try mobileOnboardingOperation(
serverUrl: serverField.text ?? "",
account: accountField.text ?? "",
repositoryPath: repositoryField.text ?? "",
applicationToken: tokenField.text ?? ""
)
}
private func discoverBranches() {
do {
let operation = try makeOperation()
begin(operation, title: "Checking Repository") { operation in
let discovery = try operation.discover()
return .discovered(discovery)
}
} catch {
present(error)
}
}
private func runSetup(useExisting: Bool) {
guard branches.indices.contains(selectedBranch) else { return }
do {
let operation = try makeOperation()
let branch = branches[selectedBranch]
begin(operation, title: useExisting ? "Opening Store" : "Cloning Store") { operation in
let outcome = try operation.setup(branch: branch, useExisting: useExisting)
return .completed(outcome)
}
} catch {
present(error)
}
}
private func begin(
_ operation: MobileOnboardingOperation,
title: String,
work: @escaping @Sendable (MobileOnboardingOperation) throws -> WorkResult
) {
cancelWork()
generation += 1
let current = generation
self.operation = operation
isWorking = true
showProgress(title: title, detail: "Preparing secure storage.")
tableView.reloadData()
progressTask = Task { [weak self] in
while !Task.isCancelled {
do {
try await Task.sleep(for: .milliseconds(150))
} catch {
return
}
guard !Task.isCancelled, let self, current == generation else { return }
let progress = operation.progress()
showProgress(title: progress.title, detail: progress.detail)
}
}
workTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<WorkResult, OnboardingFailure>.success(try work(operation))
} catch let error as MobileOnboardingFfiError {
return .failure(OnboardingFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard let self, current == generation else { return }
finish(result)
}
}
private func finish(_ result: Result<WorkResult, OnboardingFailure>) {
progressTask?.cancel()
progressTask = nil
operation = nil
isWorking = false
navigationItem.titleView = nil
navigationItem.prompt = nil
switch result {
case let .success(.discovered(discovery)):
branches = discovery.branches
selectedBranch = min(Int(discovery.selectedBranch), max(branches.count - 1, 0))
tableView.reloadData()
case let .success(.completed(outcome)):
tokenField.text = nil
let alert = UIAlertController(title: outcome.title, message: outcome.detail, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
present(alert, animated: true)
case let .failure(failure):
tableView.reloadData()
present(failure)
}
}
private func chooseBranch(from source: UIView?) {
let sheet = UIAlertController(title: "Branch", message: nil, preferredStyle: .actionSheet)
for (index, branch) in branches.enumerated() {
sheet.addAction(UIAlertAction(title: branch, style: .default) { [weak self] _ in
self?.selectedBranch = index
self?.tableView.reloadSections(IndexSet(integer: 1), with: .automatic)
})
}
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
sheet.popoverPresentationController?.sourceView = source
sheet.popoverPresentationController?.sourceRect = source?.bounds ?? .zero
present(sheet, animated: true)
}
private func present(_ error: Error) {
if let error = error as? MobileOnboardingFfiError {
present(OnboardingFailure(error))
} else {
present(.unexpected)
}
}
private func present(_ failure: OnboardingFailure) {
let alert = UIAlertController(title: failure.title, message: failure.detail, preferredStyle: .alert)
if failure.kind == .existingClone, !branches.isEmpty {
alert.addAction(UIAlertAction(title: "Use Existing", style: .default) { [weak self] _ in
self?.runSetup(useExisting: true)
})
}
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
private func showProgress(title: String, detail: String) {
let spinner = UIActivityIndicatorView(style: .medium)
spinner.startAnimating()
spinner.accessibilityLabel = "In progress"
let label = UILabel()
label.text = title
label.font = .preferredFont(forTextStyle: .headline)
label.adjustsFontForContentSizeCategory = true
let stack = UIStackView(arrangedSubviews: [spinner, label])
stack.spacing = 8
navigationItem.titleView = stack
navigationItem.prompt = detail
}
private func cancelWork() {
generation += 1
operation?.cancel()
operation = nil
workTask?.cancel()
workTask = nil
progressTask?.cancel()
progressTask = nil
navigationItem.prompt = nil
navigationItem.titleView = nil
isWorking = false
}
}
private enum WorkResult: Sendable {
case discovered(MobileOnboardingDiscovery)
case completed(MobileOnboardingOutcome)
}
private struct OnboardingFailure: Error, Sendable {
let kind: MobileOnboardingErrorKind?
let title: String
let detail: String
init(_ error: MobileOnboardingFfiError) {
switch error {
case let .Failed(kind, title, detail):
self.kind = kind
self.title = title
self.detail = detail
}
}
static let unexpected = OnboardingFailure(
kind: nil,
title: "Setup Failed",
detail: "IronStorage could not complete setup."
)
private init(kind: MobileOnboardingErrorKind?, title: String, detail: String) {
self.kind = kind
self.title = title
self.detail = detail
}
}