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

4499 lines
167 KiB
Swift

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 .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 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
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
cell.accessoryType = .disclosureIndicator
cell.accessibilityLabel = "\(row.title), \(row.detail)"
cell.accessibilityHint = row.kind == .directory
? "Opens this password folder."
: "Opens the locked password viewer."
return cell
}
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."
)
private 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
}
}