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

6890 lines
258 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
import AVFoundation
import Vision
import VisionKit
import WatchConnectivity
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 ironStorageWatchStatusDidChange = Notification.Name(
"de.rfc1437.ironstorage.watch-status-did-change"
)
static let ironStorageKeyMaterialDidChange = Notification.Name(
"de.rfc1437.ironstorage.key-material-did-change"
)
static let ironStorageConfigurationDidChange = Notification.Name(
"de.rfc1437.ironstorage.configuration-did-change"
)
}
@MainActor
private final class WatchSnapshotCoordinator: NSObject, WCSessionDelegate {
private static let snapshotKey = "de.rfc1437.ironstorage.watch.snapshot"
private static let deliveredReceiptKey = "de.rfc1437.ironstorage.watch.delivered"
private let authentication: MobileAuthentication?
private var session: WCSession?
private var syncTask: Task<Void, Never>?
private var generation = 0
var isSupported: Bool { WCSession.isSupported() }
var isPaired: Bool { session?.isPaired ?? false }
var isWatchAppInstalled: Bool { session?.isWatchAppInstalled ?? false }
init(authentication: MobileAuthentication?) {
self.authentication = authentication
super.init()
for name in [
Notification.Name.ironStorageWatchSnapshotDidChange,
.ironStorageLocalStoreDidChange,
.ironStorageAuthenticationDidChange,
UIApplication.didBecomeActiveNotification,
] {
NotificationCenter.default.addObserver(
self,
selector: #selector(synchronize),
name: name,
object: nil
)
}
guard WCSession.isSupported() else {
recordUnavailable(unpaired: false, detail: "Apple Watch connectivity is unavailable on this device.")
return
}
let session = WCSession.default
self.session = session
session.delegate = self
session.activate()
}
deinit {
syncTask?.cancel()
session?.delegate = nil
NotificationCenter.default.removeObserver(self)
}
@objc func synchronize() {
generation += 1
syncTask?.cancel()
guard let authentication else { return }
guard let session, session.activationState == .activated else { return }
guard session.isPaired else {
try? session.updateApplicationContext([:])
recordUnavailable(unpaired: true, detail: "Pair an Apple Watch to synchronize selected TOTP entries.")
return
}
guard session.isWatchAppInstalled, let directory = session.watchDirectoryURL else {
try? session.updateApplicationContext([:])
recordUnavailable(unpaired: false, detail: "Install IronStorage on the paired Apple Watch.")
return
}
let pairingIdentity = directory.lastPathComponent
let current = generation
syncTask = Task { [weak self] in
let result = await Task.detached(priority: .utility) {
Result { try authentication.prepareWatchSnapshot(platformPairingIdentity: pairingIdentity) }
}.value
guard !Task.isCancelled, let self, current == generation else { return }
switch result {
case var .success(transfer):
var snapshot = Data(transfer.snapshot)
defer {
snapshot.resetBytes(in: 0..<snapshot.count)
transfer.snapshot.removeAll(keepingCapacity: false)
}
let payload: [String: Any] = [
Self.snapshotKey: snapshot,
Self.deliveredReceiptKey: Data(transfer.deliveredReceipt),
]
publish(payload, through: session)
notifyStatusChanged()
case .failure:
// A locked GPG key leaves the previous replacement pending. Authentication
// changes trigger a fresh attempt without persisting snapshot bytes in Swift.
notifyStatusChanged()
}
}
}
private func publish(_ payload: [String: Any], through session: WCSession) {
for transfer in session.outstandingUserInfoTransfers
where transfer.userInfo[Self.snapshotKey] != nil {
transfer.cancel()
}
try? session.updateApplicationContext(payload)
session.transferUserInfo(payload)
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in }
}
}
private func recordUnavailable(unpaired: Bool, detail: String) {
guard let authentication else { return }
_ = try? authentication.setWatchSnapshotUnavailable(unpaired: unpaired, detail: detail)
notifyStatusChanged()
}
private func notifyStatusChanged() {
NotificationCenter.default.post(name: .ironStorageWatchStatusDidChange, object: nil)
}
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor [weak self] in self?.synchronize() }
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {
Task { @MainActor [weak self] in
try? session.updateApplicationContext([:])
self?.recordUnavailable(unpaired: true, detail: "The active Apple Watch changed; a fresh snapshot is required.")
}
}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
nonisolated func sessionWatchStateDidChange(_ session: WCSession) {
Task { @MainActor [weak self] in self?.synchronize() }
}
nonisolated private func acknowledge(_ userInfo: [String: Any]) {
guard
let receipt = userInfo["de.rfc1437.ironstorage.watch.delivered"] as? Data
else { return }
Task { @MainActor [weak self] in
guard let self, let authentication else { return }
_ = try? authentication.acknowledgeWatchSnapshot(receipt: receipt)
notifyStatusChanged()
}
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
acknowledge(userInfo)
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
acknowledge(message)
}
}
@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
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyMaterialDidChange),
name: .ironStorageConfigurationDidChange,
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 lazy var watchSnapshot = WatchSnapshotCoordinator(authentication: authentication)
private var navigationControllers: [UINavigationController] = []
private var restoreTask: Task<Void, Never>?
private var authenticationMonitor: Task<Void, Never>?
deinit {
restoreTask?.cancel()
authenticationMonitor?.cancel()
}
func makeRootController() -> UIViewController {
watchSnapshot.synchronize()
let shell = mobileShellFixture(state: .loading)
navigationControllers = shell.pages.map { page in
let root: UIViewController = switch page.tab {
case .passwords:
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
case .search:
PasswordSearchViewController(shellPage: page, authentication: authentication)
case .totp:
TotpListViewController(shellPage: page, authentication: authentication)
case .preferences:
PreferencesViewController(
page: page,
authentication: authentication,
watchSnapshot: watchSnapshot
)
case .home:
ShellViewController(page: page, authentication: authentication)
}
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
if let appearance = try? authentication?.mobileAppearance() {
tabs.overrideUserInterfaceStyle = switch appearance {
case .system: .unspecified
case .light: .light
case .dark: .dark
}
}
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 let authentication: MobileAuthentication?
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 commit(String)
case fetch
case pull
case push
var changesLocalStore: Bool {
if case .pull = self { return true }
return false
}
}
init(page: MobilePage, authentication: MobileAuthentication?) {
shellTab = page.tab
self.authentication = authentication
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
)
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
let actions = homeActions(at: indexPath).map { action in
let contextual = UIContextualAction(style: .normal, title: action.title) {
[weak self] _, _, completion in
self?.perform(action)
completion(true)
}
contextual.image = UIImage(systemName: action.systemImage)
contextual.backgroundColor = actionColor(action.kind)
return contextual
}
guard !actions.isEmpty else { return nil }
let configuration = UISwipeActionsConfiguration(actions: actions)
configuration.performsFirstActionWithFullSwipe = actions.count == 1
return configuration
}
override func tableView(
_ tableView: UITableView,
contextMenuConfigurationForRowAt indexPath: IndexPath,
point: CGPoint
) -> UIContextMenuConfiguration? {
let actions = homeActions(at: indexPath)
guard !actions.isEmpty else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
UIMenu(children: actions.map { action in
UIAction(
title: action.title,
image: UIImage(systemName: action.systemImage)
) { [weak self] _ in self?.perform(action) }
})
}
}
@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 {
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(SetupChoiceViewController(), animated: true)
}
@objc private func statusRefreshRequested() {
runHome(.fetch)
}
@objc private func cancelHomeRequested() {
homeOperation?.cancel()
navigationItem.leftBarButtonItem?.isEnabled = false
navigationItem.prompt = "Cancelling the current Git action…"
}
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
cell.accessibilityCustomActions = accessibilityActions(row.actions)
case .incoming:
configureCommit(
homePage.incoming[indexPath.row],
content: &content,
cell: cell
)
cell.accessibilityCustomActions = accessibilityActions(
homePage.incoming[indexPath.row].actions
)
case .outgoing:
configureCommit(
homePage.outgoing[indexPath.row],
content: &content,
cell: cell
)
cell.accessibilityCustomActions = accessibilityActions(
homePage.outgoing[indexPath.row].actions
)
case .empty:
content.image = UIImage(systemName: "checkmark.circle")
content.text = homePage.remoteConfigured ? "No Remote Activity" : "Local Store Ready"
content.secondaryText = homePage.remoteConfigured
? "There are no commits to pull or push."
: "Changes are committed to the local Git repository."
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 {
if !page.remoteConfigured {
return page.freshness == .current
? "Current local Git status."
: "Local Git status from this device."
}
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(authentication: authentication)
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(authentication: authentication)
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 let .commit(message): try operation.commit(message: message)
case .fetch: try operation.fetch()
case .pull: try operation.pull()
case .push: try operation.push()
}
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
navigationItem.leftBarButtonItem = nil
refreshControl?.endRefreshing()
switch result {
case let .success(homePage):
self.homePage = homePage
contentUnavailableConfiguration = nil
tableView.reloadData()
if request?.changesLocalStore == true {
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
NotificationCenter.default.post(
name: .ironStorageWatchSnapshotDidChange,
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)
presentHomeFailure(failure, retry: request ?? .fetch)
} else if failure.kind == .interrupted {
UIAccessibility.post(notification: .announcement, argument: failure.title)
} else {
presentHomeFailure(failure, retry: request ?? .fetch)
}
}
}
private func showHomeLoading() {
var configuration = UIContentUnavailableConfiguration.loading()
configuration.text = "Loading Activity"
configuration.secondaryText = "Reading Git history and password-store status."
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, retry: HomeRequest) {
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
guard let tabs = self?.tabBarController else { return }
tabs.selectedIndex = tabs.viewControllers?.firstIndex { controller in
guard let navigation = controller as? UINavigationController else { return false }
return (navigation.viewControllers.first as? MobileTabRoot)?.shellTab == .preferences
} ?? tabs.selectedIndex
})
}
alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in
self?.runHome(retry)
})
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
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "Cancel",
style: .plain,
target: self,
action: #selector(cancelHomeRequested)
)
}
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
navigationItem.leftBarButtonItem = nil
refreshControl?.endRefreshing()
navigationItem.rightBarButtonItem?.isEnabled = true
}
private func homeActions(at indexPath: IndexPath) -> [MobileHomeAction] {
guard let homePage, homeSections.indices.contains(indexPath.section) else { return [] }
return switch homeSections[indexPath.section] {
case .summary where homePage.summaries.indices.contains(indexPath.row):
homePage.summaries[indexPath.row].actions
case .incoming where homePage.incoming.indices.contains(indexPath.row):
homePage.incoming[indexPath.row].actions
case .outgoing where homePage.outgoing.indices.contains(indexPath.row):
homePage.outgoing[indexPath.row].actions
default:
[]
}
}
private func perform(_ action: MobileHomeAction) {
switch action.kind {
case .commit: promptForCommitMessage()
case .fetch: runHome(.fetch)
case .pull: runHome(.pull)
case .push: runHome(.push)
}
}
private func promptForCommitMessage() {
let alert = UIAlertController(
title: "Commit Changes",
message: "Enter the message for all current password-store changes.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "Commit message"
field.autocapitalizationType = .sentences
field.returnKeyType = .done
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Commit", style: .default) { [weak self, weak alert] _ in
self?.runHome(.commit(alert?.textFields?.first?.text ?? ""))
})
present(alert, animated: true)
}
private func accessibilityActions(_ actions: [MobileHomeAction]) -> [UIAccessibilityCustomAction] {
actions.map { action in
UIAccessibilityCustomAction(name: action.title) { [weak self] _ in
self?.perform(action)
return true
}
}
}
private func actionColor(_ kind: MobileHomeActionKind) -> UIColor {
switch kind {
case .commit: .systemOrange
case .fetch: .systemGray
case .pull: .systemBlue
case .push: .systemGreen
}
}
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 preferences: MobilePreferences?
private var gitIdentity: MobileGitIdentity?
private let watchSnapshot: WatchSnapshotCoordinator
private var preferenceTask: Task<Void, Never>?
private var loadTask: Task<Void, Never>?
private var loadGeneration = 0
init(
page: MobilePage,
authentication: MobileAuthentication?,
watchSnapshot: WatchSnapshotCoordinator
) {
self.page = page
self.authentication = authentication
self.watchSnapshot = watchSnapshot
super.init(style: .insetGrouped)
title = page.title
navigationItem.largeTitleDisplayMode = .always
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageAuthenticationDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageWatchStatusDidChange,
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 ? 7 : 0
}
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
switch section {
case 0: 2
case 1: preferences?.syncConfigured == false ? 1 : 3
case 2, 3: 3
default: 1
}
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
switch section {
case 0: "Password Store"
case 1: preferences?.syncConfigured == false ? "Git Sync" : "Application Token"
case 2: "GPG Key & Recovery"
case 3: "Authentication"
case 4: "Appearance"
case 5: "Apple Watch"
default: "Git Commit Identity"
}
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
switch section {
case 0:
"Non-secret repository and server identities from the shared configuration."
case 1:
preferences?.syncConfigured == false
? "The store has local Git history. An HTTPS remote can be configured later for synchronization."
: "The application token is stored only in protected system storage and is never displayed."
case 2:
"Import provides initial or recovery key setup. Private-key transfers require explicit confirmation and passphrase validation."
case 3:
"Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
case 5:
"Pairing is read from the device; TOTP selection and synchronization state come from Rust."
case 6:
"Used as the author for commits created by the iPhone app."
default:
nil
}
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
switch (indexPath.section, indexPath.row) {
case (0, 0):
content.image = UIImage(systemName: "externaldrive.fill")
content.text = preferences?.repositoryTitle ?? "Password Store"
content.secondaryText = preferences?.repositoryUrl ?? "Repository unavailable"
cell.selectionStyle = .none
case (0, 1):
content.image = UIImage(systemName: "server.rack")
content.text = preferences?.serverTitle ?? "HTTPS Server"
content.secondaryText = preferences?.serverIdentity ?? "Server identity unavailable"
cell.selectionStyle = .none
case (1, 0):
if preferences?.syncConfigured == false {
content.image = UIImage(systemName: "network.badge.shield.half.filled")
content.text = "Add HTTPS Remote"
content.secondaryText = "Keep local history and enable optional synchronization"
cell.accessoryType = .disclosureIndicator
} else {
content.image = UIImage(systemName: "key.fill")
content.text = preferences?.applicationAccount ?? "No Application Token"
content.secondaryText = preferences?.applicationAccount == nil
? "Git network operations require a replacement token"
: "Token stored in protected system storage"
}
cell.selectionStyle = .none
case (1, 1):
content.image = UIImage(systemName: "arrow.triangle.2.circlepath")
content.text = "Replace Token"
content.secondaryText = "Store a new HTTPS application token"
cell.accessoryType = .disclosureIndicator
case (1, 2):
content.image = UIImage(systemName: "trash")
content.text = "Remove Token"
content.textProperties.color = .systemRed
content.secondaryText = "Disable authenticated Git network operations"
cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = preferences?.applicationAccount != nil
cell.contentView.alpha = preferences?.applicationAccount == nil ? 0.45 : 1
case (2, 0):
content.image = UIImage(systemName: "person.badge.key.fill")
content.text = preferences?.defaultKeyTitle ?? "Default GPG Key"
content.secondaryText = preferences?.defaultKeyFingerprint ?? "Key unavailable"
cell.selectionStyle = .none
case (2, 1), (2, 2):
let importing = indexPath.row == 1
content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode")
content.text = importing ? "Import GPG Key" : "Export GPG Key"
content.secondaryText = importing
? "Scan an initial or recovery key transfer"
: "Display public or private key armor"
cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = keyTransfer != nil
cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1
case (3, 0):
content.image = UIImage(systemName: "timer")
content.text = "Inactivity Timeout"
content.secondaryText = "\(preferences?.authenticationTimeoutSeconds ?? 0) seconds"
cell.accessoryType = .disclosureIndicator
case (3, 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
case (3, 2):
let unlocked = state?.unlocked == true
content.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
content.text = "Lock Now"
content.textProperties.color = .systemRed
content.secondaryText = unlocked
? "Revoke all active authentication handles"
: "IronStorage is already locked"
cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = unlocked
cell.contentView.alpha = unlocked ? 1 : 0.45
case (4, 0):
content.image = UIImage(systemName: "circle.lefthalf.filled")
content.text = "App Appearance"
content.secondaryText = appearanceTitle(preferences?.appearance ?? .system)
cell.accessoryType = .disclosureIndicator
case (5, 0):
content.image = UIImage(systemName: watchImage(preferences?.watchState))
content.text = preferences?.watchTitle ?? "Checking Apple Watch"
content.secondaryText = preferences?.watchDetail ?? "Reading pairing state"
cell.accessoryType = .disclosureIndicator
default:
content.image = UIImage(systemName: "person.crop.circle")
content.text = gitIdentity?.name ?? "Git Commit Identity"
content.secondaryText = gitIdentity?.email ?? "Set the commit author name and email"
cell.accessoryType = .disclosureIndicator
}
content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content
cell.accessibilityLabel = [content.text, content.secondaryText]
.compactMap { $0 }
.joined(separator: ", ")
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
switch (indexPath.section, indexPath.row) {
case (1, 0) where preferences?.syncConfigured == false:
navigationController?.pushViewController(
OnboardingViewController(attachLocalStore: true),
animated: true
)
case (1, 1):
tokenUpdateRequested()
case (1, 2):
removeTokenRequested()
case (2, 1), (2, 2):
guard let keyTransfer else { return }
if indexPath.row == 1 {
requestKeyScanner(keyTransfer)
} else {
navigationController?.pushViewController(
KeyExportListViewController(transfer: keyTransfer),
animated: true
)
}
case (3, 0):
editAuthenticationTimeout()
case (3, 2):
lockNow()
case (4, 0):
chooseAppearance()
case (5, 0):
openWatchPreference()
case (6, 0):
editGitIdentity()
default:
break
}
}
private func lockNow() {
guard 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)
}
private func editGitIdentity() {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
let alert = UIAlertController(
title: "Git Commit Identity",
message: "Enter the author used for commits created on this iPhone.",
preferredStyle: .alert
)
alert.addTextField { [gitIdentity] field in
field.placeholder = "Name"
field.text = gitIdentity?.name
field.textContentType = .name
field.autocapitalizationType = .words
field.clearButtonMode = .whileEditing
}
alert.addTextField { [gitIdentity] field in
field.placeholder = "Email"
field.text = gitIdentity?.email
field.textContentType = .emailAddress
field.keyboardType = .emailAddress
field.autocapitalizationType = .none
field.autocorrectionType = .no
field.clearButtonMode = .whileEditing
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in
guard
let self,
let fields = alert?.textFields,
fields.count == 2
else { return }
saveGitIdentity(
authentication,
name: fields[0].text ?? "",
email: fields[1].text ?? ""
)
})
present(alert, animated: true)
}
private func saveGitIdentity(
_ authentication: MobileAuthentication,
name: String,
email: String
) {
preferenceTask?.cancel()
preferenceTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileGitIdentity, AuthenticationFailure>.success(
try authentication.setGitIdentity(name: name, email: email)
)
} 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(identity):
gitIdentity = identity
tableView.reloadSections(IndexSet(integer: 6), with: .automatic)
UIAccessibility.post(
notification: .announcement,
argument: "Git commit identity updated"
)
case let .failure(failure):
presentAuthenticationFailure(failure)
}
}
}
private func removeTokenRequested() {
guard preferences?.applicationAccount != nil else { return }
let alert = UIAlertController(
title: "Remove Application Token?",
message: "Local password browsing remains available, but Fetch, Pull, and Push will fail until a replacement token is stored.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Remove Token", style: .destructive) {
[weak self] _ in
guard let authentication = self?.authentication else { return }
self?.runPreferenceUpdate(announcement: "Application token removed") {
try authentication.removeApplicationToken()
}
})
present(alert, animated: true)
}
private func editAuthenticationTimeout() {
let alert = UIAlertController(
title: "Inactivity Timeout",
message: "Enter a shared timeout from 1 to 86400 seconds.",
preferredStyle: .alert
)
alert.addTextField { [preferences] field in
field.keyboardType = .numberPad
field.text = String(preferences?.authenticationTimeoutSeconds ?? 120)
field.accessibilityLabel = "Timeout in seconds"
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Save", style: .default) {
[weak self, weak alert] _ in
let seconds = UInt64(alert?.textFields?.first?.text ?? "") ?? 0
guard let authentication = self?.authentication else { return }
self?.runPreferenceUpdate(announcement: "Inactivity timeout updated") {
try authentication.setAuthenticationTimeout(seconds: seconds)
}
})
present(alert, animated: true)
}
private func chooseAppearance() {
let alert = UIAlertController(title: "App Appearance", message: nil, preferredStyle: .actionSheet)
for appearance in [MobileAppearance.system, .light, .dark] {
let selected = appearance == preferences?.appearance
alert.addAction(UIAlertAction(
title: "\(selected ? "" : "")\(appearanceTitle(appearance))",
style: .default
) { [weak self] _ in
guard let authentication = self?.authentication else { return }
self?.runPreferenceUpdate(announcement: "Appearance updated") {
try authentication.setMobileAppearance(appearance: appearance)
}
})
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.popoverPresentationController?.sourceView = tableView
alert.popoverPresentationController?.sourceRect = tableView.bounds
present(alert, animated: true)
}
private func runPreferenceUpdate(
announcement: String,
operation: @escaping @Sendable () throws -> Void
) {
preferenceTask?.cancel()
preferenceTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try operation()
return Result<Void, AuthenticationFailure>.success(())
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case .success:
refreshState()
UIAccessibility.post(notification: .announcement, argument: announcement)
case let .failure(failure):
presentAuthenticationFailure(failure)
}
}
}
private func openWatchPreference() {
switch preferences?.watchState {
case .ready, .pending, .delivered, .current:
selectTotpTab()
case .failed:
let alert = UIAlertController(
title: "Apple Watch Synchronization Failed",
message: preferences?.watchDetail ?? "The latest snapshot could not be synchronized.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in
self?.watchSnapshot.synchronize()
})
present(alert, animated: true)
case .notPaired:
presentWatchGuidance(
title: "Pair an Apple Watch",
message: "Open the Watch app on this iPhone and pair an Apple Watch before configuring IronStorage synchronization."
)
case .appNotInstalled:
presentWatchGuidance(
title: "Install IronStorage on Apple Watch",
message: "Open the Watch app on this iPhone and install the IronStorage companion on the paired watch."
)
default:
presentWatchGuidance(
title: "Apple Watch Unavailable",
message: "Apple Watch connectivity is unavailable on this device."
)
}
}
private func presentWatchGuidance(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
private func selectTotpTab() {
guard let controllers = tabBarController?.viewControllers else { return }
tabBarController?.selectedIndex = controllers.firstIndex { controller in
guard
let navigation = controller as? UINavigationController,
let root = navigation.viewControllers.first as? MobileTabRoot
else { return false }
return root.shellTab == .totp
} ?? tabBarController?.selectedIndex ?? 0
}
private func appearanceTitle(_ appearance: MobileAppearance) -> String {
switch appearance {
case .system: "System"
case .light: "Light"
case .dark: "Dark"
}
}
private func watchImage(_ state: MobileWatchPreferenceState?) -> String {
switch state {
case .ready: "applewatch.radiowaves.left.and.right"
case .pending: "arrow.triangle.2.circlepath"
case .delivered: "arrow.down.circle"
case .current: "checkmark.circle.fill"
case .failed: "exclamationmark.triangle"
case .notPaired, .appNotInstalled: "applewatch.slash"
default: "applewatch"
}
}
@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()
gitIdentity = try? authentication?.gitIdentity()
preferences = try? authentication?.preferences(
watchSupported: watchSnapshot.isSupported,
watchPaired: watchSnapshot.isPaired,
watchAppInstalled: watchSnapshot.isWatchAppInstalled
)
if let appearance = preferences?.appearance {
tabBarController?.overrideUserInterfaceStyle = switch appearance {
case .system: .unspecified
case .light: .light
case .dark: .dark
}
}
tableView.reloadData()
}
}
@MainActor
private final class KeyImportScannerViewController: UIViewController,
DataScannerViewControllerDelegate
{
private let importer: MobileKeyTransferImport
private let localSetup: Bool
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()
localSetup = false
super.init(nibName: nil, bundle: nil)
title = "Import GPG Key"
navigationItem.largeTitleDisplayMode = .never
}
init(localImporter: MobileKeyTransferImport) {
importer = localImporter
localSetup = true
super.init(nibName: nil, bundle: nil)
title = "Import Local 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
if localSetup, !privateKey {
completing = false
presentFailure("A local store requires a private GPG key with an encryption subkey.") {
[weak self] in try? self?.scanner.startScanning()
}
return
}
let warning = privateKey
? "Import this private key only if you trust the device that displayed it."
: "Import this public key?"
let alert = UIAlertController(
title: localSetup
? "Use This GPG Key?"
: (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)
})
if !localSetup {
alert.addAction(UIAlertAction(title: "Import", style: .default) {
[weak self, weak alert] _ in
self?.finishImport(
passphrase: alert?.textFields?.first?.text,
makeDefault: false
)
})
}
if localSetup && privateKey {
alert.addAction(UIAlertAction(title: "Use with Existing Store", style: .default) {
[weak self, weak alert] _ in
self?.finishImport(
passphrase: alert?.textFields?.first?.text,
makeDefault: false
)
})
}
if privateKey {
alert.addAction(UIAlertAction(
title: localSetup ? "Create Local Store" : "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, overwrite: Bool = false) {
do {
let outcome = try importer.import(
passphrase: passphrase,
makeDefault: makeDefault,
overwrite: overwrite
)
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 let error as MobileKeyTransferFfiError {
if case let .Failed(kind, _) = error, kind == .existingKey, !overwrite {
confirmOverwrite(passphrase: passphrase, makeDefault: makeDefault)
return
}
completing = false
presentFailure(error.localizedDescription) { [weak self] in
guard let self else { return }
try? scanner.startScanning()
}
} catch {
completing = false
presentFailure(error.localizedDescription) { [weak self] in
guard let self else { return }
try? scanner.startScanning()
}
}
}
private func confirmOverwrite(passphrase: String?, makeDefault: Bool) {
let alert = UIAlertController(
title: "Replace Existing GPG Key?",
message: "A key with this fingerprint already exists. Replacing key material can change which private key is used to unlock entries.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
self?.completing = false
try? self?.scanner.startScanning()
})
alert.addAction(UIAlertAction(title: "Replace Key", style: .destructive) {
[weak self] _ in
self?.finishImport(
passphrase: passphrase,
makeDefault: makeDefault,
overwrite: true
)
})
present(alert, animated: true)
}
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 TotpDiscoveryView: UIView {
private let spinner = UIActivityIndicatorView(style: .medium)
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
init(cancel: @escaping () -> Void) {
super.init(frame: .zero)
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textAlignment = .center
detailLabel.font = .preferredFont(forTextStyle: .subheadline)
detailLabel.adjustsFontForContentSizeCategory = true
detailLabel.textColor = .secondaryLabel
detailLabel.textAlignment = .center
detailLabel.numberOfLines = 0
progressView.accessibilityLabel = "TOTP discovery progress"
let cancelButton = UIButton(type: .system, primaryAction: UIAction(title: "Cancel") { _ in
cancel()
})
let progressRow = UIStackView(arrangedSubviews: [spinner, progressView])
progressRow.alignment = .center
progressRow.spacing = 12
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, progressRow, cancelButton])
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 24),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
stack.leadingAnchor.constraint(greaterThanOrEqualTo: readableContentGuide.leadingAnchor),
stack.trailingAnchor.constraint(lessThanOrEqualTo: readableContentGuide.trailingAnchor),
progressView.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
func apply(_ progress: MobileTotpDiscoveryProgress) {
titleLabel.text = switch progress.phase {
case .preparing: "Preparing TOTP Discovery"
case .inspecting: "Discovering TOTP Entries"
case .saving: "Saving Protected Cache"
case .complete: "TOTP Discovery Complete"
case .cancelled: "Cancelling TOTP Discovery"
}
if progress.total == 0 {
spinner.startAnimating()
progressView.isHidden = true
detailLabel.text = "Reading the password-store inventory."
} else {
spinner.stopAnimating()
progressView.isHidden = false
progressView.progress = Float(progress.inspected) / Float(progress.total)
progressView.accessibilityValue = "\(progress.inspected) of \(progress.total)"
detailLabel.text =
"\(progress.inspected) of \(progress.total) inspected • "
+ "\(progress.cacheHits) cached • \(progress.matches) TOTP"
+ (progress.unavailable == 0 ? "" : "\(progress.unavailable) unavailable")
}
}
}
@MainActor
private final class TotpListViewController: UITableViewController, MobileTabRoot,
UISearchResultsUpdating
{
fileprivate let shellTab = MobileTab.totp
private let authentication: MobileAuthentication?
private let searchController = UISearchController(searchResultsController: nil)
private var shellPage: MobilePage
private var page: MobileTotpPage?
private var unfilteredPage: MobileTotpPage?
private var loadTask: Task<Void, Never>?
private var searchTask: Task<Void, Never>?
private var unlockTask: Task<Void, Never>?
private var shellTask: Task<Void, Never>?
private var progressTask: Task<Void, Never>?
private var operation: MobileTotpOperation?
private var loadGeneration = 0
private var searchGeneration = 0
private var refreshAfterUnlock = false
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
self.shellPage = shellPage
self.authentication = authentication
super.init(style: .insetGrouped)
title = shellPage.title
navigationItem.largeTitleDisplayMode = .always
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.placeholder = "TOTP entry names and folders"
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.autocorrectionType = .no
searchController.searchBar.spellCheckingType = .no
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
definesPresentationContext = true
refreshControl = UIRefreshControl()
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
operation?.cancel()
loadTask?.cancel()
searchTask?.cancel()
unlockTask?.cancel()
shellTask?.cancel()
progressTask?.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
)
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageWatchStatusDidChange,
object: nil
)
refreshState()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadShell()
}
func updateSearchResults(for searchController: UISearchController) {
search(searchController.searchBar.text ?? "")
}
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."
let cache = page.cacheNotice.map { " \($0)" } ?? ""
return page.watch.detail + unavailable + cache
}
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] else { return }
guard (try? authentication?.state().unlocked) == true else {
unlock(passphrase: nil, row: row)
return
}
open(row)
}
private func open(_ row: MobileTotpRow) {
guard 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() {
if (try? authentication?.state().unlocked) == true {
loadPage()
} else {
refreshAfterUnlock = true
refreshControl?.endRefreshing()
unlockRequested()
}
}
@objc private func authenticationDidChange() {
if (try? authentication?.state().unlocked) != true {
operation?.cancel()
}
refreshState()
}
@objc private func localStoreDidChange() {
operation?.cancel()
loadTask?.cancel()
searchTask?.cancel()
progressTask?.cancel()
loadGeneration += 1
searchGeneration += 1
operation = nil
progressTask = nil
tableView.tableHeaderView = nil
page = nil
unfilteredPage = nil
loadCachedPage()
}
@objc private func unlockRequested() {
unlock(passphrase: nil, row: 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 {
searchTask?.cancel()
searchGeneration += 1
page = nil
unfilteredPage = nil
showUnavailable(
title: shellPage.stateTitle,
detail: shellPage.stateDetail,
image: shellPage.systemImage
)
return
}
if force, (try? authentication?.state().unlocked) == true {
loadPage()
} else if page == nil, loadTask == nil {
loadCachedPage()
} else if page != nil {
configureLockButton()
}
}
private func reloadShell() {
shellTask?.cancel()
shellTask = Task { [weak self] in
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
guard
!Task.isCancelled,
let self,
let page = shell.pages.first(where: { $0.tab == .totp })
else { return }
shellPage = page
refreshState()
}
}
private func unlock(passphrase: String?, row: MobileTotpRow?) {
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 {
let state = if let row {
try authentication.unlockEntry(path: row.path, passphrase: passphrase)
} else {
try authentication.unlockTotp(passphrase: passphrase)
}
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
state
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
contentUnavailableConfiguration = nil
switch result {
case .success:
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
if refreshAfterUnlock || page == nil {
refreshAfterUnlock = false
loadPage()
} else if let row {
open(row)
} else {
configureLockButton()
}
UIAccessibility.post(notification: .announcement, argument: "TOTP unlocked")
case let .failure(failure)
where passphrase == nil
&& (failure.kind == .passphraseRequired
|| failure.kind == .biometryUnavailable):
promptForPassphrase(message: failure.detail, row: row)
case let .failure(failure):
if failure.kind != .cancelled { handle(failure) }
}
}
}
private func promptForPassphrase(message: String, row: MobileTotpRow?) {
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, row: row)
})
present(alert, animated: true)
}
private func loadPage() {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
operation?.cancel()
loadTask?.cancel()
progressTask?.cancel()
loadGeneration += 1
let current = loadGeneration
let operation = mobileTotpOperation()
self.operation = operation
navigationItem.rightBarButtonItems = nil
let discoveryView = TotpDiscoveryView { [weak self] in
self?.cancelDiscovery()
}
apply(operation.progress(), to: discoveryView)
tableView.tableHeaderView = discoveryView
contentUnavailableConfiguration = nil
progressTask = Task { [weak self] in
while !Task.isCancelled {
do {
try await Task.sleep(for: .milliseconds(150))
} catch {
return
}
guard !Task.isCancelled, let self, current == loadGeneration else { return }
apply(operation.progress(), to: discoveryView)
}
}
loadTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileTotpPage, AuthenticationFailure>.success(
try authentication.totpPage(operation: operation)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self, current == loadGeneration else { return }
progressTask?.cancel()
progressTask = nil
self.operation = nil
loadTask = nil
tableView.tableHeaderView = nil
refreshControl?.endRefreshing()
configureLockButton()
switch result {
case let .success(page):
apply(page)
case let .failure(failure):
if failure.kind == .cancelled {
loadCachedPage()
} else {
handle(failure)
}
}
}
}
private func loadCachedPage() {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
loadTask?.cancel()
loadTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileTotpPage?, AuthenticationFailure>.success(
try authentication.cachedTotpPage()
)
} 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(.some(page)):
apply(page)
case .success(.none):
if (try? authentication.state().unlocked) == true {
loadPage()
} else {
showLocked()
}
case let .failure(failure):
handle(failure)
}
}
}
private func apply(_ page: MobileTotpPage, remember: Bool = true) {
if remember {
unfilteredPage = page
let query = searchController.searchBar.text ?? ""
if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
search(query)
return
}
}
self.page = page
configureLockButton()
tableView.reloadData()
if page.rows.isEmpty {
let query = searchController.searchBar.text ?? ""
let searching = !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
showUnavailable(
title: searching ? "No Results" : "No TOTP Codes",
detail: searching
? "No cached TOTP entries match “\(query)”."
: "No valid time-based OTP entries were found in the password store.",
image: searching ? "magnifyingglass" : "timer"
)
} else {
contentUnavailableConfiguration = nil
}
}
private func search(_ query: String) {
searchGeneration += 1
let generation = searchGeneration
searchTask?.cancel()
guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
if let unfilteredPage { apply(unfilteredPage, remember: false) }
return
}
guard let authentication else { return }
searchTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileTotpPage?, AuthenticationFailure>.success(
try authentication.searchCachedTotpPage(query: query)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self, generation == searchGeneration else { return }
switch result {
case let .success(.some(page)):
apply(page, remember: false)
case .success(.none):
break
case let .failure(failure):
handle(failure)
}
}
}
private func configureLockButton() {
let unlocked = (try? authentication?.state().unlocked) == true
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill"),
style: .plain,
target: self,
action: unlocked ? #selector(lockRequested) : #selector(unlockRequested)
)
navigationItem.rightBarButtonItem?.accessibilityLabel =
unlocked ? "Lock IronStorage" : "Unlock IronStorage"
}
private func showLocked() {
page = nil
unfilteredPage = nil
tableView.reloadData()
navigationItem.rightBarButtonItem = nil
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "lock.fill")
configuration.text = "TOTP Is Locked"
configuration.secondaryText =
"Unlock once to discover time-based one-time-password entries."
configuration.button = .filled()
configuration.button.title = "Unlock"
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
self?.unlockRequested()
}
contentUnavailableConfiguration = configuration
refreshControl?.endRefreshing()
}
private func cancelDiscovery() {
operation?.cancel()
}
private func apply(_ progress: MobileTotpDiscoveryProgress, to view: TotpDiscoveryView) {
view.apply(progress)
let width = tableView.bounds.width
let height = view.systemLayoutSizeFitting(
CGSize(width: width, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
).height
view.frame = CGRect(x: 0, y: 0, width: width, height: height)
if tableView.tableHeaderView === view {
tableView.tableHeaderView = view
}
}
private func handle(_ failure: AuthenticationFailure) {
if failure.kind == .expired {
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
refreshState()
}
presentAuthenticationFailure(failure)
}
private func showLoading(_ title: String) {
tableView.backgroundView = nil
var configuration = UIContentUnavailableConfiguration.loading()
configuration.text = title
configuration.secondaryText = "Reading OTP metadata in secure storage."
var background = UIBackgroundConfiguration.clear()
background.backgroundColor = .systemGroupedBackground
configuration.background = background
contentUnavailableConfiguration = configuration
}
private func showUnavailable(title: String, detail: String, image: String) {
tableView.backgroundView = nil
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: image)
configuration.text = title
configuration.secondaryText = detail
contentUnavailableConfiguration = configuration
refreshControl?.endRefreshing()
}
}
@MainActor
private final class TotpCodeView: UIView {
private let issuerLabel = UILabel()
private let accountLabel = UILabel()
private let codeLabel = UILabel()
private let countdownLabel = UILabel()
private let progress = UIProgressView(progressViewStyle: .default)
var copyRequested: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
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
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20),
])
codeLabel.addGestureRecognizer(
UITapGestureRecognizer(target: self, action: #selector(copyCode))
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
func apply(_ detail: MobileTotpDetail) {
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: " "))"
updateCountdown(detail)
}
func updateCountdown(_ detail: MobileTotpDetail) {
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
}
func clear() {
issuerLabel.text = nil
accountLabel.text = nil
codeLabel.text = nil
countdownLabel.text = nil
progress.progress = 0
}
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 }
}
}
@objc private func copyCode() {
copyRequested?()
}
}
@MainActor
private final class TotpDetailViewController: UITableViewController {
private let authentication: MobileAuthentication
private var detail: MobileTotpDetail
private let codeView = TotpCodeView()
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
)
NotificationCenter.default.addObserver(
self,
selector: #selector(entryDidChange),
name: .ironStorageWatchStatusDidChange,
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() {
codeView.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1)
codeView.copyRequested = { [weak self] in self?.copyRequested() }
tableView.tableHeaderView = codeView
}
private func apply(_ detail: MobileTotpDetail) {
self.detail = detail
title = detail.issuer ?? detail.account
codeView.apply(detail)
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() {
codeView.updateCountdown(detail)
}
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() {
codeView.flashCopied()
}
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 = ""
codeView.clear()
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 = ""
codeView.clear()
if UIPasteboard.general.string == copiedValue {
UIPasteboard.general.items = []
}
copiedValue = nil
navigationItem.rightBarButtonItem = nil
watchSwitch.isEnabled = false
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "lock.fill")
configuration.text = "TOTP Is Locked"
configuration.secondaryText = "The code was removed when IronStorage locked."
configuration.button = .plain()
configuration.button.title = "Back to TOTP"
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
}
contentUnavailableConfiguration = configuration
UIAccessibility.post(notification: .announcement, argument: "TOTP locked")
}
}
private func currentUnixSeconds() -> UInt64 {
UInt64(max(Date().timeIntervalSince1970, 0))
}
private func groupedCode(_ code: String) -> String {
let midpoint = code.index(code.startIndex, offsetBy: code.count / 2)
return String(code[..<midpoint]) + " " + String(code[midpoint...])
}
private extension UInt64 {
func saturatingSubtracting(_ value: UInt64) -> UInt64 {
self > value ? self - value : 0
}
}
@MainActor
private final class PasswordMutationCoordinator {
private weak var presenter: UIViewController?
private let authentication: MobileAuthentication?
private var operationTask: Task<Void, Never>?
private var working = false
private var contextualCompletion: ((Bool) -> Void)?
init(presenter: UIViewController, authentication: MobileAuthentication?) {
self.presenter = presenter
self.authentication = authentication
}
deinit {
operationTask?.cancel()
}
func accessoryView(for row: MobilePasswordRow) -> UIView {
let button = UIButton(type: .system)
button.frame = CGRect(x: 0, y: 0, width: 44, height: 44)
button.configuration = .plain()
button.configuration?.image = UIImage(systemName: "ellipsis.circle")
button.configuration?.buttonSize = .medium
button.menu = menu(for: row)
button.showsMenuAsPrimaryAction = true
button.accessibilityLabel = "Actions for \(row.title)"
return button
}
func accessibilityActions(for row: MobilePasswordRow) -> [UIAccessibilityCustomAction] {
[
accessibilityAction("Move", image: "folder", action: .move, row: row),
accessibilityAction("Copy", image: "doc.on.doc", action: .copy, row: row),
accessibilityAction("Delete", image: "trash", action: .delete, row: row),
]
}
func swipeConfiguration(for row: MobilePasswordRow) -> UISwipeActionsConfiguration {
let delete = UIContextualAction(style: .destructive, title: "Delete") {
[weak self] _, _, completion in
self?.begin(.delete, row: row, completion: completion)
}
delete.image = UIImage(systemName: "trash")
let copy = UIContextualAction(style: .normal, title: "Copy") {
[weak self] _, _, completion in
self?.begin(.copy, row: row, completion: completion)
}
copy.image = UIImage(systemName: "doc.on.doc")
copy.backgroundColor = .systemBlue
let move = UIContextualAction(style: .normal, title: "Move") {
[weak self] _, _, completion in
self?.begin(.move, row: row, completion: completion)
}
move.image = UIImage(systemName: "folder")
move.backgroundColor = .systemOrange
let configuration = UISwipeActionsConfiguration(actions: [delete, copy, move])
configuration.performsFirstActionWithFullSwipe = true
return configuration
}
private func menu(for row: MobilePasswordRow) -> UIMenu {
UIMenu(children: [
UIAction(title: "Move", image: UIImage(systemName: "folder")) { [weak self] _ in
self?.begin(.move, row: row)
},
UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in
self?.begin(.copy, row: row)
},
UIAction(
title: "Delete",
image: UIImage(systemName: "trash"),
attributes: .destructive
) { [weak self] _ in
self?.begin(.delete, row: row)
},
])
}
private func accessibilityAction(
_ title: String,
image: String,
action: MobileMutationAction,
row: MobilePasswordRow
) -> UIAccessibilityCustomAction {
UIAccessibilityCustomAction(
name: title,
image: UIImage(systemName: image)
) { [weak self] _ in
self?.begin(action, row: row)
return true
}
}
private func begin(
_ action: MobileMutationAction,
row: MobilePasswordRow,
completion: ((Bool) -> Void)? = nil
) {
guard !working else {
completion?(false)
return
}
guard let authentication else {
completion?(false)
presenter?.presentAuthenticationFailure(.unavailable)
return
}
working = true
contextualCompletion = completion
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileMutationPlan, AuthenticationFailure>.success(
try authentication.prepareEntryMutation(path: row.path, action: action)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
operationTask = nil
switch result {
case let .success(plan):
present(plan)
case let .failure(failure):
fail(failure)
}
}
}
private func present(_ plan: MobileMutationPlan) {
switch plan.action {
case .delete:
confirmDelete(plan)
case .move, .copy:
guard !plan.destinations.isEmpty else {
fail(AuthenticationFailure(
kind: .entry,
title: "No Destination Available",
detail: "Create another password folder before moving or copying this entry."
))
return
}
let destinations = PasswordDestinationViewController(
plan: plan,
selected: { [weak self] destination in
self?.presenter?.dismiss(animated: true) {
self?.destinationSelected(destination, plan: plan)
}
},
cancelled: { [weak self] in
self?.presenter?.dismiss(animated: true) { self?.finish(false) }
}
)
let navigation = UINavigationController(rootViewController: destinations)
navigation.modalPresentationStyle = .formSheet
presenter?.present(navigation, animated: true)
}
}
private func destinationSelected(
_ destination: MobileMutationDestination,
plan: MobileMutationPlan
) {
if destination.requiresOverwrite {
let verb = plan.action == .move ? "Move" : "Copy"
let alert = UIAlertController(
title: "Replace Existing Password?",
message: "\(destination.title) already contains “\(plan.sourceTitle)”. \(verb) and replace it?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(title: "Replace", style: .destructive) {
[weak self] _ in
self?.confirmEditorIfNeeded(plan, destination: destination.path, overwrite: true)
})
presenter?.present(alert, animated: true)
} else {
confirmEditorIfNeeded(plan, destination: destination.path, overwrite: false)
}
}
private func confirmDelete(_ plan: MobileMutationPlan) {
let suffix = plan.hasDirtyEditor
? " Unsaved changes in its open editor will also be discarded."
: " This cant be undone."
let alert = UIAlertController(
title: "Delete “\(plan.sourceTitle)”?",
message: "The encrypted password entry will be permanently deleted.\(suffix)",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) {
[weak self] _ in
self?.perform(
plan,
destination: nil,
overwrite: false,
discardEditor: plan.hasOpenEditor,
mayAuthenticate: true
)
})
presenter?.present(alert, animated: true)
}
private func confirmEditorIfNeeded(
_ plan: MobileMutationPlan,
destination: String,
overwrite: Bool
) {
guard plan.hasOpenEditor else {
perform(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: false,
mayAuthenticate: true
)
return
}
let verb = plan.action == .move ? "Move" : "Copy"
let alert = UIAlertController(
title: plan.hasDirtyEditor ? "Discard Changes and \(verb)?" : "Close Editor and \(verb)?",
message: plan.hasDirtyEditor
? "Unsaved changes in the open editor for “\(plan.sourceTitle)” will be discarded."
: "The open editor for “\(plan.sourceTitle)” must close before this action.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(
title: plan.hasDirtyEditor ? "Discard and \(verb)" : "Close and \(verb)",
style: plan.hasDirtyEditor ? .destructive : .default
) { [weak self] _ in
self?.perform(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: true,
mayAuthenticate: true
)
})
presenter?.present(alert, animated: true)
}
private func perform(
_ plan: MobileMutationPlan,
destination: String?,
overwrite: Bool,
discardEditor: Bool,
mayAuthenticate: Bool
) {
guard let authentication else {
fail(.unavailable)
return
}
let request = MobileMutationRequest(
action: plan.action,
source: plan.source,
revision: plan.revision,
destination: destination,
confirmed: plan.action == .delete,
overwrite: overwrite,
discardEditor: discardEditor
)
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileMutationOutcome, AuthenticationFailure>.success(
try authentication.performEntryMutation(request: request)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
operationTask = nil
switch result {
case let .success(outcome):
UINotificationFeedbackGenerator().notificationOccurred(.success)
UIAccessibility.post(notification: .announcement, argument: outcome.detail)
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
finish(true)
case let .failure(failure)
where mayAuthenticate && failure.kind == .expired:
unlockAndRetry(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
passphrase: nil
)
case let .failure(failure):
fail(failure)
}
}
}
private func unlockAndRetry(
_ plan: MobileMutationPlan,
destination: String?,
overwrite: Bool,
discardEditor: Bool,
passphrase: String?
) {
guard let authentication else {
fail(.unavailable)
return
}
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
_ = try authentication.unlockEntry(path: plan.source, passphrase: passphrase)
return Result<Void, AuthenticationFailure>.success(())
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
operationTask = nil
switch result {
case .success:
perform(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
mayAuthenticate: false
)
case let .failure(failure)
where passphrase == nil
&& (failure.kind == .passphraseRequired
|| failure.kind == .biometryUnavailable):
promptForPassphrase(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
message: failure.detail
)
case let .failure(failure):
fail(failure)
}
}
}
private func promptForPassphrase(
_ plan: MobileMutationPlan,
destination: String?,
overwrite: Bool,
discardEditor: Bool,
message: String
) {
let alert = UIAlertController(
title: "GPG Key Passphrase",
message: message,
preferredStyle: .alert
)
alert.addTextField { field in
field.isSecureTextEntry = true
field.textContentType = .password
field.placeholder = "Passphrase"
field.returnKeyType = .go
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) {
[weak self] _ in self?.finish(false)
})
alert.addAction(UIAlertAction(title: "Unlock", style: .default) {
[weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text, !value.isEmpty else {
self?.finish(false)
return
}
alert?.textFields?.first?.text = nil
self?.unlockAndRetry(
plan,
destination: destination,
overwrite: overwrite,
discardEditor: discardEditor,
passphrase: value
)
})
presenter?.present(alert, animated: true)
}
private func fail(_ failure: AuthenticationFailure) {
presenter?.presentAuthenticationFailure(failure)
finish(false)
}
private func finish(_ applied: Bool) {
contextualCompletion?(applied)
contextualCompletion = nil
working = false
}
}
@MainActor
private final class PasswordDestinationViewController: UITableViewController {
private let plan: MobileMutationPlan
private let selected: (MobileMutationDestination) -> Void
private let cancelled: () -> Void
init(
plan: MobileMutationPlan,
selected: @escaping (MobileMutationDestination) -> Void,
cancelled: @escaping () -> Void
) {
self.plan = plan
self.selected = selected
self.cancelled = cancelled
super.init(style: .insetGrouped)
title = plan.action == .move ? "Move to Folder" : "Copy to Folder"
navigationItem.largeTitleDisplayMode = .never
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.cancelled() }
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
plan.destinations.count
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
plan.sourceTitle
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let destination = plan.destinations[indexPath.row]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: "folder")
content.text = destination.title
content.secondaryText = destination.requiresOverwrite
? "\(destination.detail) · Replaces existing password"
: destination.detail
content.secondaryTextProperties.numberOfLines = 2
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
cell.accessibilityHint = destination.requiresOverwrite
? "Requires confirmation before replacing the existing password."
: "Selects this destination folder."
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
selected(plan.destinations[indexPath.row])
}
}
@MainActor
private final class PasswordSearchViewController: UITableViewController, MobileTabRoot,
UISearchResultsUpdating
{
fileprivate let shellTab = MobileTab.search
private let authentication: MobileAuthentication?
private let searchController = UISearchController(searchResultsController: nil)
private var shellPage: MobilePage
private var searchPage: MobilePasswordPage?
private var searchTask: Task<Void, Never>?
private var shellTask: Task<Void, Never>?
private var generation = 0
private lazy var mutationCoordinator = PasswordMutationCoordinator(
presenter: self,
authentication: authentication
)
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
self.shellPage = shellPage
self.authentication = authentication
super.init(style: .insetGrouped)
title = shellPage.title
navigationItem.largeTitleDisplayMode = .always
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.placeholder = "Entry names and folders"
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.autocorrectionType = .no
searchController.searchBar.spellCheckingType = .no
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
definesPresentationContext = true
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
searchTask?.cancel()
shellTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
apply(shellPage)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadShell()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if searchController.searchBar.text?.isEmpty != false {
searchController.isActive = true
searchController.searchBar.becomeFirstResponder()
}
}
override func numberOfSections(in tableView: UITableView) -> Int {
searchPage?.rows.isEmpty == false ? 1 : 0
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
searchPage?.rows.count ?? 0
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
guard let row = searchPage?.rows[indexPath.row] else { return cell }
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: row.systemImage)
content.text = row.title
content.secondaryText = row.detail
content.textProperties.numberOfLines = 2
content.secondaryTextProperties.numberOfLines = 2
cell.contentConfiguration = content
cell.accessoryView = mutationCoordinator.accessoryView(for: row)
cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row)
cell.accessibilityLabel = "\(row.title), in \(row.detail)"
cell.accessibilityHint = "Opens the locked password viewer. More actions follow."
return cell
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
guard let rows = searchPage?.rows, rows.indices.contains(indexPath.row) else { return nil }
let row = rows[indexPath.row]
return mutationCoordinator.swipeConfiguration(for: row)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let row = searchPage?.rows[indexPath.row] else { return }
navigationController?.pushViewController(
LockedPasswordViewController(entry: row, authentication: authentication),
animated: true
)
}
func updateSearchResults(for searchController: UISearchController) {
search(searchController.searchBar.text ?? "")
}
@objc private func localStoreDidChange() {
guard viewIfLoaded?.window != nil else { return }
search(searchController.searchBar.text ?? "")
}
private func reloadShell() {
shellTask?.cancel()
shellTask = Task { [weak self] in
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
guard
!Task.isCancelled,
let self,
let page = shell.pages.first(where: { $0.tab == .search })
else { return }
apply(page)
}
}
private func apply(_ page: MobilePage) {
shellPage = page
title = page.title
guard page.state == .ready else {
searchTask?.cancel()
searchPage = nil
tableView.reloadData()
var configuration = page.state == .loading
? UIContentUnavailableConfiguration.loading()
: UIContentUnavailableConfiguration.empty()
configuration.image = page.state == .loading
? nil
: UIImage(systemName: page.state == .error ? "exclamationmark.triangle" : "lock.shield")
configuration.text = page.stateTitle
configuration.secondaryText = page.stateDetail
contentUnavailableConfiguration = configuration
return
}
search(searchController.searchBar.text ?? "")
}
private func search(_ query: String) {
generation += 1
let currentGeneration = generation
searchTask?.cancel()
guard shellPage.state == .ready else { return }
guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
searchPage = nil
tableView.reloadData()
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "magnifyingglass")
configuration.text = "Search Passwords"
configuration.secondaryText = "Search by entry name or folder."
contentUnavailableConfiguration = configuration
return
}
searchPage = nil
tableView.reloadData()
var loading = UIContentUnavailableConfiguration.loading()
loading.text = "Searching Passwords"
contentUnavailableConfiguration = loading
searchTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobilePasswordPage, PasswordFailure>.success(
try mobilePasswordSearch(query: query)
)
} catch let error as MobilePasswordFfiError {
return .failure(PasswordFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self, currentGeneration == generation else { return }
finish(result, query: query)
}
}
private func finish(
_ result: Result<MobilePasswordPage, PasswordFailure>,
query: String
) {
switch result {
case let .success(page):
searchPage = page
tableView.reloadData()
if page.rows.isEmpty {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "magnifyingglass")
configuration.text = "No Results"
configuration.secondaryText = "No password entries match “\(query)”."
contentUnavailableConfiguration = configuration
} else {
contentUnavailableConfiguration = nil
UIAccessibility.post(
notification: .announcement,
argument: "\(page.rows.count) password results"
)
}
case let .failure(failure):
searchPage = nil
tableView.reloadData()
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "exclamationmark.triangle")
configuration.text = failure.title
configuration.secondaryText = failure.detail
contentUnavailableConfiguration = configuration
}
}
}
@MainActor
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.passwords
private let path: String?
private let authentication: MobileAuthentication?
private var shellPage: MobilePage?
private var directoryPage: MobilePasswordPage?
private var loadTask: Task<Void, Never>?
private var createTask: Task<Void, Never>?
private var loadGeneration = 0
private lazy var mutationCoordinator = PasswordMutationCoordinator(
presenter: self,
authentication: authentication
)
init(
shellPage: MobilePage,
authentication: MobileAuthentication?,
path: String? = nil
) {
self.shellPage = shellPage
self.authentication = authentication
self.path = path
super.init(style: .insetGrouped)
title = shellPage.title
navigationItem.largeTitleDisplayMode = path == nil ? .always : .never
refreshControl = UIRefreshControl()
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
}
private init(path: String, title: String, authentication: MobileAuthentication?) {
self.path = path
self.authentication = authentication
shellPage = nil
super.init(style: .insetGrouped)
self.title = title
navigationItem.largeTitleDisplayMode = .never
refreshControl = UIRefreshControl()
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageLocalStoreDidChange,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
loadTask?.cancel()
createTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
if let shellPage {
apply(shellPage)
} else {
showLoading()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reload()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadGeneration += 1
loadTask?.cancel()
refreshControl?.endRefreshing()
}
override func numberOfSections(in tableView: UITableView) -> Int {
directoryPage?.rows.isEmpty == false ? 1 : 0
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
directoryPage?.rows.count ?? 0
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
guard let row = directoryPage?.rows[indexPath.row] else { return cell }
var content = cell.defaultContentConfiguration()
content.image = UIImage(systemName: row.systemImage)
content.text = row.title
content.secondaryText = row.detail
content.textProperties.numberOfLines = 2
content.secondaryTextProperties.numberOfLines = 1
cell.contentConfiguration = content
if row.kind == .entry {
cell.accessoryView = mutationCoordinator.accessoryView(for: row)
cell.accessibilityCustomActions = mutationCoordinator.accessibilityActions(for: row)
} else {
cell.accessoryType = .disclosureIndicator
}
cell.accessibilityLabel = "\(row.title), \(row.detail)"
cell.accessibilityHint = row.kind == .directory
? "Opens this password folder."
: "Opens the locked password viewer. More actions follow."
return cell
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
guard let rows = directoryPage?.rows, rows.indices.contains(indexPath.row) else { return nil }
let row = rows[indexPath.row]
guard row.kind == .entry else { return nil }
return mutationCoordinator.swipeConfiguration(for: row)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let row = directoryPage?.rows[indexPath.row] else { return }
let controller: UIViewController = switch row.kind {
case .directory:
PasswordDirectoryViewController(
path: row.path,
title: row.title,
authentication: authentication
)
case .entry:
LockedPasswordViewController(entry: row, authentication: authentication)
}
navigationController?.pushViewController(controller, animated: true)
}
@objc private func refreshRequested() {
reload()
}
@objc private func localStoreDidChange() {
guard viewIfLoaded?.window != nil else { return }
reload()
}
@objc private func createRequested() {
let alert = UIAlertController(
title: "New Password",
message: "Enter a name for the password entry in this folder.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "Entry Name"
field.autocapitalizationType = .words
field.clearButtonMode = .whileEditing
field.returnKeyType = .next
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return }
self?.beginCreate(name: name, passphrase: nil)
})
present(alert, animated: true)
}
private func createFolderRequested() {
let alert = UIAlertController(
title: "New Folder",
message: "Enter a name for the password folder in this folder.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "Folder Name"
field.autocapitalizationType = .words
field.clearButtonMode = .whileEditing
field.returnKeyType = .go
}
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?.beginCreateFolder(name: name)
})
present(alert, animated: true)
}
private func beginCreateFolder(name: String) {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
createTask?.cancel()
let parent = path ?? ""
createTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try authentication.createDirectory(parent: parent, name: name)
return Result<Void, AuthenticationFailure>.success(())
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case .success:
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
reload()
case let .failure(failure):
presentAuthenticationFailure(failure)
}
}
}
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
let add = UIBarButtonItem(
barButtonSystemItem: .add,
target: nil,
action: nil
)
add.menu = UIMenu(children: [
UIAction(title: "New Password", image: UIImage(systemName: "key")) {
[weak self] _ in self?.createRequested()
},
UIAction(title: "New Folder", image: UIImage(systemName: "folder.badge.plus")) {
[weak self] _ in self?.createFolderRequested()
},
])
add.accessibilityLabel = "Create password or folder"
navigationItem.rightBarButtonItem = add
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 let totpCodeView = TotpCodeView()
private var unlockTask: Task<Void, Never>?
private var entryTask: Task<Void, Never>?
private var totpTask: Task<Void, Never>?
private var totpTimerTask: 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 totp: MobileTotpDetail?
private var cacheNotice: String?
private var copiedTotpValue: String?
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()
totpTask?.cancel()
totpTimerTask?.cancel()
clipboardTask?.cancel()
feedbackTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshState()
}
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 {
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,
titleForFooterInSection section: Int
) -> String? {
guard let page, section == page.sections.indices.last else { return nil }
return cacheNotice
}
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<MobileEntryPresentation, AuthenticationFailure>.success(
try authentication.entryPage(
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 }
switch result {
case let .success(presentation):
page = presentation.page
totp = presentation.totp
cacheNotice = presentation.cacheNotice
title = presentation.page.title
contentUnavailableConfiguration = nil
installLockButton()
configureTotpHeader()
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 configureTotpHeader() {
guard let totp else {
totpTimerTask?.cancel()
totpCodeView.clear()
tableView.tableHeaderView = nil
return
}
totpCodeView.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1)
totpCodeView.copyRequested = { [weak self] in self?.copyTotpCode() }
totpCodeView.apply(totp)
tableView.tableHeaderView = totpCodeView
startTotpTimer()
}
private func startTotpTimer() {
totpTimerTask?.cancel()
totpTimerTask = Task { [weak self] in
while !Task.isCancelled {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
guard let self, let totp else { return }
if currentUnixSeconds() >= totp.validUntil {
refreshTotpCode()
} else {
totpCodeView.updateCountdown(totp)
}
}
}
}
private func refreshTotpCode() {
guard totpTask == nil, let authentication else { return }
let path = entry.path
totpTask = 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 }
totpTask = nil
guard !Task.isCancelled else { return }
switch result {
case let .success(detail):
totp = detail
totpCodeView.apply(detail)
case let .failure(failure):
handle(failure)
}
}
}
private func copyTotpCode() {
guard totpTask == nil, let authentication else { return }
let path = entry.path
totpTask = 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 let self else { return }
totpTask = nil
guard !Task.isCancelled else { return }
switch result {
case let .success(copy):
UIPasteboard.general.string = copy.value
copiedTotpValue = copy.value
totpCodeView.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.copiedTotpValue = nil
}
case let .failure(failure):
handle(failure)
}
}
}
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()
totpTask?.cancel()
totpTimerTask?.cancel()
feedbackTask?.cancel()
page = nil
totp = nil
cacheNotice = nil
totpCodeView.clear()
tableView.tableHeaderView = nil
if UIPasteboard.general.string == copiedTotpValue {
UIPasteboard.general.items = []
}
copiedTotpValue = 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.keyboardDismissMode = .interactive
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.setEditing(true, animated: false)
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.cancelRequested() }
)
let save = 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"
navigationItem.rightBarButtonItems = [save, add]
let generate = UIBarButtonItem(
title: "Generate Password",
image: UIImage(systemName: "wand.and.stars"),
primaryAction: UIAction { [weak self] _ in self?.generateRequested() }
)
toolbarItems = [
UIBarButtonItem(systemItem: .flexibleSpace),
generate,
UIBarButtonItem(systemItem: .flexibleSpace),
]
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, UITextFieldDelegate, 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.delegate = self
field.returnKeyType = .done
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 textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
func textViewDidChange(_ textView: UITextView) {
currentValue = textView.text
changed?(currentName, currentValue)
}
private func toggleReveal() {
guard sensitive else { return }
valueField.isSecureTextEntry.toggle()
revealButton.configuration?.image = UIImage(
systemName: valueField.isSecureTextEntry ? "eye" : "eye.slash"
)
revealButton.accessibilityLabel = valueField.isSecureTextEntry
? "Reveal field"
: "Hide field"
}
}
private struct AuthenticationFailure: Error, Sendable {
let kind: MobileAuthenticationErrorKind
let title: String
let detail: String
init(_ error: MobileAuthenticationFfiError) {
switch error {
case let .Failed(kind, title, detail):
self.kind = kind
self.title = title
self.detail = detail
}
}
static let unexpected = AuthenticationFailure(
kind: .secureStorage,
title: "Unlock Failed",
detail: "IronStorage could not complete authentication."
)
static let unavailable = AuthenticationFailure(
kind: .configuration,
title: "Authentication Is Unavailable",
detail: "Finish password-store setup before unlocking entries."
)
fileprivate init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
self.kind = kind
self.title = title
self.detail = detail
}
}
@MainActor
private extension UIViewController {
func presentAuthenticationFailure(_ failure: AuthenticationFailure) {
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
private struct HomeFailure: Error, Sendable {
let kind: MobileHomeErrorKind
let title: String
let detail: String
init(_ error: MobileHomeFfiError) {
switch error {
case let .Failed(kind, title, detail):
self.kind = kind
self.title = title
self.detail = detail
}
}
static let unexpected = HomeFailure(
kind: .repository,
title: "Remote Activity Failed",
detail: "IronStorage could not load the storage-provided Home page."
)
private init(kind: MobileHomeErrorKind, title: String, detail: String) {
self.kind = kind
self.title = title
self.detail = detail
}
}
@MainActor
private final class MobileCommitActivityViewController: UITableViewController {
private let commit: MobileHomeCommit
init(commit: MobileHomeCommit, incoming: Bool) {
self.commit = commit
super.init(style: .insetGrouped)
title = incoming ? "Incoming Commit" : "Outgoing Commit"
navigationItem.largeTitleDisplayMode = .never
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 1 : max(commit.changes.count, 1)
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
section == 0 ? "Commit" : "Password Store Changes"
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
if indexPath.section == 0 {
content.image = UIImage(systemName: commit.systemImage)
content.text = commit.title
content.secondaryText = "\(commit.detail)\n\(formattedDate)"
} else if commit.changes.isEmpty {
content.image = UIImage(systemName: "minus.circle")
content.text = "No File Changes"
content.secondaryText = "This commit does not change password-store files."
} else {
let change = commit.changes[indexPath.row]
content.image = UIImage(systemName: change.systemImage)
content.text = change.title
content.secondaryText = change.detail
}
content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content
cell.selectionStyle = .none
return cell
}
private var formattedDate: String {
Date(timeIntervalSince1970: TimeInterval(commit.timestamp)).formatted(
date: .abbreviated,
time: .shortened
)
}
}
@MainActor
private final class TokenUpdateViewController: UITableViewController {
private let accountField = UITextField()
private let tokenField = UITextField()
private var updateTask: Task<Void, Never>?
private var generation = 0
private var isWorking = false
init() {
super.init(style: .insetGrouped)
title = "Update Token"
navigationItem.largeTitleDisplayMode = .never
for field in [accountField, tokenField] {
field.borderStyle = .none
field.clearButtonMode = .whileEditing
field.autocorrectionType = .no
field.autocapitalizationType = .none
field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body)
}
accountField.placeholder = "Account name"
tokenField.placeholder = "New application token"
tokenField.textContentType = .oneTimeCode
tokenField.isSecureTextEntry = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
updateTask?.cancel()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
generation += 1
updateTask?.cancel()
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 2 : 1
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
section == 0 ? "HTTPS Credential" : nil
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
section == 0
? "The existing token is never displayed. Updating replaces it only in protected system storage."
: nil
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
if indexPath.section == 1 {
cell.textLabel?.text = isWorking ? "Updating…" : "Replace Token"
cell.textLabel?.textColor = isWorking ? .secondaryLabel : view.tintColor
cell.textLabel?.textAlignment = .center
cell.isUserInteractionEnabled = !isWorking
return cell
}
let field = indexPath.row == 0 ? accountField : tokenField
field.translatesAutoresizingMaskIntoConstraints = false
field.isEnabled = !isWorking
cell.contentView.addSubview(field)
NSLayoutConstraint.activate([
field.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor),
field.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor),
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 10),
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -10)
])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1, !isWorking else { return }
replaceToken()
}
private func replaceToken() {
generation += 1
let current = generation
isWorking = true
navigationItem.prompt = "Saving in protected system storage."
tableView.reloadData()
let account = accountField.text ?? ""
let token = tokenField.text ?? ""
updateTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try replaceConfiguredMobileApplicationToken(
account: account,
applicationToken: token
)
return Result<Void, OnboardingFailure>.success(())
} catch let error as MobileOnboardingFfiError {
return .failure(OnboardingFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard let self, current == generation else { return }
isWorking = false
navigationItem.prompt = nil
tableView.reloadData()
switch result {
case .success:
tokenField.text = nil
let alert = UIAlertController(
title: "Token Updated",
message: "The new application token is ready for HTTPS Git operations.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Done", style: .default) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
present(alert, animated: true)
case let .failure(failure):
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
}
}
}
@MainActor
private final class SetupChoiceViewController: UITableViewController {
init() {
super.init(style: .insetGrouped)
title = "Set Up IronStorage"
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 ? 2 : 1
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
section == 0 ? "Local Store" : "Existing Git Store"
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
section == 0
? "Creates a local Git repository. Add an HTTPS remote later when you want to sync."
: "Clone an existing password-store repository over HTTPS."
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
switch (indexPath.section, indexPath.row) {
case (0, 0):
content.image = UIImage(systemName: "key.badge.plus")
content.text = "Generate Local GPG Key"
content.secondaryText = "Create a protected key and local Git history"
case (0, 1):
content.image = UIImage(systemName: "qrcode.viewfinder")
content.text = "Import Local GPG Key"
content.secondaryText = "Use a private key with a new or existing store"
default:
content.image = UIImage(systemName: "network")
content.text = "Connect Existing Store"
content.secondaryText = "Clone an HTTPS Git repository"
}
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)
switch (indexPath.section, indexPath.row) {
case (0, 0):
navigationController?.pushViewController(LocalSetupViewController(), animated: true)
case (0, 1):
requestLocalKeyScanner()
default:
navigationController?.pushViewController(OnboardingViewController(), animated: true)
}
}
private func requestLocalKeyScanner() {
guard DataScannerViewController.isSupported else {
presentMessage("This device does not support live QR scanning.")
return
}
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
showLocalKeyScanner()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
Task { @MainActor in
if granted {
self?.showLocalKeyScanner()
} else {
self?.presentMessage("Allow camera access in Settings to import a GPG key.")
}
}
}
default:
presentMessage("Allow camera access in Settings to import a GPG key.")
}
}
private func showLocalKeyScanner() {
guard DataScannerViewController.isAvailable else {
presentMessage("Close other camera apps and try again.")
return
}
navigationController?.pushViewController(
KeyImportScannerViewController(localImporter: mobileLocalKeyTransferImporter()),
animated: true
)
}
private func presentMessage(_ detail: String) {
let alert = UIAlertController(
title: "GPG Key Import Is Unavailable",
message: detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
@MainActor
private final class LocalSetupViewController: UITableViewController {
private let keyNameField = UITextField()
private let passphraseField = UITextField()
private let confirmationField = UITextField()
private var task: Task<Void, Never>?
private var operation: MobileLocalOnboardingOperation?
private var isWorking = false
init() {
super.init(style: .insetGrouped)
title = "Create Local Store"
navigationItem.largeTitleDisplayMode = .never
configureFields()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
operation?.cancel()
task?.cancel()
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 3 : 1
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
section == 0 ? "Protected GPG Key" : nil
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
section == 0
? "The passphrase protects the generated private key. Keep it somewhere safe; IronStorage cannot recover it."
: "Creates a pass-compatible password store with local Git versioning and no network connection."
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
if indexPath.section == 1 {
cell.textLabel?.text = isWorking ? "Creating…" : "Create Local Store"
cell.textLabel?.textAlignment = .center
cell.textLabel?.textColor = isWorking ? .secondaryLabel : view.tintColor
cell.isUserInteractionEnabled = !isWorking
return cell
}
let field = [keyNameField, passphraseField, confirmationField][indexPath.row]
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 }
create(replaceExistingKey: false)
}
private func configureFields() {
for field in [keyNameField, passphraseField, confirmationField] {
field.borderStyle = .none
field.clearButtonMode = .whileEditing
field.autocorrectionType = .no
field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body)
}
keyNameField.placeholder = "GPG key name"
keyNameField.text = "IronStorage Local Key"
keyNameField.textContentType = .name
keyNameField.autocapitalizationType = .words
passphraseField.placeholder = "GPG key passphrase"
confirmationField.placeholder = "Confirm passphrase"
for field in [passphraseField, confirmationField] {
field.isSecureTextEntry = true
field.textContentType = .newPassword
field.autocapitalizationType = .none
}
}
private func create(replaceExistingKey: Bool) {
let keyName = keyNameField.text ?? ""
let passphrase = passphraseField.text ?? ""
guard !keyName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!passphrase.isEmpty,
passphrase == confirmationField.text
else {
presentFailure(
title: "Check GPG Key Details",
detail: "Enter a key name and matching non-empty passphrases."
)
return
}
let operation = mobileLocalOnboardingOperation()
self.operation = operation
isWorking = true
navigationItem.prompt = "Generating a protected GPG key and local Git repository."
tableView.reloadData()
task = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileOnboardingOutcome, OnboardingFailure>.success(
try operation.generate(
userId: keyName,
passphrase: passphrase,
replaceExistingKey: replaceExistingKey
)
)
} catch let error as MobileOnboardingFfiError {
return .failure(OnboardingFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
self?.finish(result, keyName: keyName, passphrase: passphrase)
}
}
private func finish(
_ result: Result<MobileOnboardingOutcome, OnboardingFailure>,
keyName: String,
passphrase: String
) {
operation = nil
task = nil
isWorking = false
navigationItem.prompt = nil
tableView.reloadData()
switch result {
case let .success(outcome):
passphraseField.text = nil
confirmationField.text = nil
let alert = UIAlertController(
title: outcome.title,
message: outcome.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Done", style: .default) { _ in
NotificationCenter.default.post(name: .ironStorageKeyMaterialDidChange, object: nil)
})
present(alert, animated: true)
case let .failure(failure) where failure.kind == .existingKey:
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Replace Key", style: .destructive) {
[weak self] _ in
self?.keyNameField.text = keyName
self?.passphraseField.text = passphrase
self?.confirmationField.text = passphrase
self?.create(replaceExistingKey: true)
})
present(alert, animated: true)
case let .failure(failure):
presentFailure(title: failure.title, detail: failure.detail)
}
}
private func presentFailure(title: String, detail: String) {
let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
@MainActor
private final class OnboardingViewController: UITableViewController {
private let attachLocalStore: Bool
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(attachLocalStore: Bool = false) {
self.attachLocalStore = attachLocalStore
super.init(style: .insetGrouped)
title = attachLocalStore ? "Add Git Remote" : "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 {
attachLocalStore ? 2 : (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: attachLocalStore || 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 attachLocalStore {
cell.textLabel?.text = "Connect Local Store"
cell.textLabel?.textColor = view.tintColor
} else 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 attachLocalStore, indexPath.section == 1 {
connectLocalStore()
} else 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 connectLocalStore() {
do {
let operation = try makeOperation()
begin(operation, title: "Connecting Git Remote") { operation in
.completed(try operation.connectLocalStore())
}
} 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) { _ in
NotificationCenter.default.post(
name: .ironStorageConfigurationDidChange,
object: nil
)
})
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
}
}