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