Implement iPhone TOTP tab and Watch sharing
This commit is contained in:
@@ -7,6 +7,9 @@ extension Notification.Name {
|
||||
static let ironStorageAuthenticationDidChange = Notification.Name(
|
||||
"de.rfc1437.ironstorage.authentication-did-change"
|
||||
)
|
||||
static let ironStorageWatchSnapshotDidChange = Notification.Name(
|
||||
"de.rfc1437.ironstorage.watch-snapshot-did-change"
|
||||
)
|
||||
}
|
||||
|
||||
@main
|
||||
@@ -56,6 +59,8 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
let root: UIViewController = switch page.tab {
|
||||
case .passwords:
|
||||
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
|
||||
case .totp:
|
||||
TotpListViewController(shellPage: page, authentication: authentication)
|
||||
case .preferences:
|
||||
PreferencesViewController(page: page, authentication: authentication)
|
||||
default:
|
||||
@@ -888,6 +893,738 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TotpListViewController: UITableViewController, MobileTabRoot {
|
||||
fileprivate let shellTab = MobileTab.totp
|
||||
private let authentication: MobileAuthentication?
|
||||
private var shellPage: MobilePage
|
||||
private var page: MobileTotpPage?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var unlockTask: Task<Void, Never>?
|
||||
|
||||
init(shellPage: MobilePage, authentication: MobileAuthentication?) {
|
||||
self.shellPage = shellPage
|
||||
self.authentication = authentication
|
||||
super.init(style: .insetGrouped)
|
||||
title = shellPage.title
|
||||
navigationItem.largeTitleDisplayMode = .always
|
||||
refreshControl = UIRefreshControl()
|
||||
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
unlockTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(authenticationDidChange),
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(localStoreDidChange),
|
||||
name: .ironStorageLocalStoreDidChange,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(localStoreDidChange),
|
||||
name: .ironStorageWatchSnapshotDidChange,
|
||||
object: nil
|
||||
)
|
||||
refreshState()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
refreshState()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
page?.rows.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
guard let page else { return nil }
|
||||
let unavailable = page.unavailableEntries == 0
|
||||
? ""
|
||||
: " \(page.unavailableEntries) entries could not be inspected with the active key."
|
||||
return page.watch.detail + unavailable
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let row = page?.rows[indexPath.row]
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.image = UIImage(systemName: "timer")
|
||||
content.text = row?.title
|
||||
content.secondaryText = row?.detail
|
||||
content.secondaryTextProperties.numberOfLines = 2
|
||||
if row?.sharedWithWatch == true {
|
||||
content.secondaryText = [row?.detail, "Apple Watch selected"]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " • ")
|
||||
}
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.accessibilityLabel = [row?.title, row?.detail].compactMap { $0 }.joined(separator: ", ")
|
||||
cell.accessibilityValue = row?.sharedWithWatch == true ? "Selected for Apple Watch" : nil
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let row = page?.rows[indexPath.row], let authentication else { return }
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
return Result<MobileTotpDetail, AuthenticationFailure>.success(
|
||||
try authentication.totpDetail(
|
||||
path: row.path,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
switch result {
|
||||
case let .success(detail):
|
||||
navigationController?.pushViewController(
|
||||
TotpDetailViewController(authentication: authentication, detail: detail),
|
||||
animated: true
|
||||
)
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshRequested() {
|
||||
refreshState(force: true)
|
||||
}
|
||||
|
||||
@objc private func authenticationDidChange() {
|
||||
refreshState()
|
||||
}
|
||||
|
||||
@objc private func localStoreDidChange() {
|
||||
guard (try? authentication?.state().unlocked) == true else { return }
|
||||
loadPage()
|
||||
}
|
||||
|
||||
@objc private func unlockRequested() {
|
||||
unlock(passphrase: nil)
|
||||
}
|
||||
|
||||
@objc private func lockRequested() {
|
||||
guard let authentication else { return }
|
||||
do {
|
||||
try authentication.manualLock()
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
presentAuthenticationFailure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
presentAuthenticationFailure(.unexpected)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshState(force: Bool = false) {
|
||||
guard shellPage.state == .ready else {
|
||||
page = nil
|
||||
showUnavailable(
|
||||
title: shellPage.stateTitle,
|
||||
detail: shellPage.stateDetail,
|
||||
image: shellPage.systemImage
|
||||
)
|
||||
return
|
||||
}
|
||||
if (try? authentication?.state().unlocked) == true {
|
||||
if page == nil || force { loadPage() }
|
||||
} else {
|
||||
loadTask?.cancel()
|
||||
page = nil
|
||||
tableView.reloadData()
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "lock.fill")
|
||||
configuration.text = "TOTP Is Locked"
|
||||
configuration.secondaryText =
|
||||
"Authenticate to scan password entries for time-based one-time passwords."
|
||||
configuration.button = .filled()
|
||||
configuration.button.title = "Unlock"
|
||||
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
|
||||
self?.unlockRequested()
|
||||
}
|
||||
contentUnavailableConfiguration = configuration
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private func unlock(passphrase: String?) {
|
||||
guard let authentication else {
|
||||
presentAuthenticationFailure(.unavailable)
|
||||
return
|
||||
}
|
||||
unlockTask?.cancel()
|
||||
showLoading("Unlocking TOTP")
|
||||
unlockTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
|
||||
try authentication.unlockTotp(passphrase: passphrase)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
switch result {
|
||||
case .success:
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
loadPage()
|
||||
UIAccessibility.post(notification: .announcement, argument: "TOTP unlocked")
|
||||
case let .failure(failure)
|
||||
where passphrase == nil
|
||||
&& (failure.kind == .passphraseRequired
|
||||
|| failure.kind == .biometryUnavailable):
|
||||
promptForPassphrase(message: failure.detail)
|
||||
case let .failure(failure):
|
||||
if failure.kind != .cancelled { handle(failure) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func promptForPassphrase(message: String) {
|
||||
let alert = UIAlertController(
|
||||
title: "GPG Key Passphrase",
|
||||
message: message,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addTextField { field in
|
||||
field.isSecureTextEntry = true
|
||||
field.textContentType = .password
|
||||
field.placeholder = "Passphrase"
|
||||
field.returnKeyType = .go
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
||||
self?.refreshState()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
|
||||
guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return }
|
||||
alert?.textFields?.first?.text = nil
|
||||
self?.unlock(passphrase: value)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func loadPage() {
|
||||
guard let authentication else {
|
||||
presentAuthenticationFailure(.unavailable)
|
||||
return
|
||||
}
|
||||
loadTask?.cancel()
|
||||
showLoading("Loading TOTP")
|
||||
loadTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileTotpPage, AuthenticationFailure>.success(
|
||||
try authentication.totpPage()
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
refreshControl?.endRefreshing()
|
||||
switch result {
|
||||
case let .success(page):
|
||||
self.page = page
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "lock.fill"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(lockRequested)
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
|
||||
tableView.reloadData()
|
||||
if page.rows.isEmpty {
|
||||
showUnavailable(
|
||||
title: "No TOTP Codes",
|
||||
detail: "No valid time-based OTP entries were found in the password store.",
|
||||
image: "timer"
|
||||
)
|
||||
} else {
|
||||
contentUnavailableConfiguration = nil
|
||||
}
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ failure: AuthenticationFailure) {
|
||||
if failure.kind == .expired {
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
refreshState()
|
||||
} else {
|
||||
showUnavailable(title: failure.title, detail: failure.detail, image: "exclamationmark.triangle")
|
||||
}
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
|
||||
private func showLoading(_ title: String) {
|
||||
var configuration = UIContentUnavailableConfiguration.loading()
|
||||
configuration.text = title
|
||||
configuration.secondaryText = "Reading OTP metadata in secure storage."
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
private func showUnavailable(title: String, detail: String, image: String) {
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: image)
|
||||
configuration.text = title
|
||||
configuration.secondaryText = detail
|
||||
contentUnavailableConfiguration = configuration
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TotpDetailViewController: UITableViewController {
|
||||
private let authentication: MobileAuthentication
|
||||
private var detail: MobileTotpDetail
|
||||
private let codeContainer = UIView()
|
||||
private let issuerLabel = UILabel()
|
||||
private let accountLabel = UILabel()
|
||||
private let codeLabel = UILabel()
|
||||
private let countdownLabel = UILabel()
|
||||
private let progress = UIProgressView(progressViewStyle: .default)
|
||||
private let watchSwitch = UISwitch()
|
||||
private var timerTask: Task<Void, Never>?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var clipboardTask: Task<Void, Never>?
|
||||
private var copiedValue: String?
|
||||
|
||||
init(authentication: MobileAuthentication, detail: MobileTotpDetail) {
|
||||
self.authentication = authentication
|
||||
self.detail = detail
|
||||
super.init(style: .insetGrouped)
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
timerTask?.cancel()
|
||||
loadTask?.cancel()
|
||||
clipboardTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
configureHeader()
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(copyRequested)
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Copy current TOTP code"
|
||||
watchSwitch.addTarget(self, action: #selector(watchSwitchChanged(_:)), for: .valueChanged)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(authenticationDidChange),
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(entryDidChange),
|
||||
name: .ironStorageLocalStoreDidChange,
|
||||
object: nil
|
||||
)
|
||||
apply(detail)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
guard let header = tableView.tableHeaderView else { return }
|
||||
let height = header.systemLayoutSizeFitting(
|
||||
CGSize(width: tableView.bounds.width, height: 0),
|
||||
withHorizontalFittingPriority: .required,
|
||||
verticalFittingPriority: .fittingSizeLevel
|
||||
).height
|
||||
guard abs(header.frame.height - height) > 0.5 else { return }
|
||||
header.frame.size.height = height
|
||||
tableView.tableHeaderView = header
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
"Apple Watch"
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
detail.watch.detail
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = "Share with Apple Watch"
|
||||
content.secondaryText = "Time-based codes only"
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryView = watchSwitch
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
|
||||
@objc private func authenticationDidChange() {
|
||||
guard (try? authentication.state().unlocked) == true else {
|
||||
lockDetail()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func entryDidChange() {
|
||||
refreshCode()
|
||||
}
|
||||
|
||||
@objc private func copyRequested() {
|
||||
loadTask?.cancel()
|
||||
let authentication = authentication
|
||||
let path = detail.path
|
||||
loadTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
return Result<MobileEntryCopy, AuthenticationFailure>.success(
|
||||
try authentication.copyTotpCode(
|
||||
path: path,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
loadTask = nil
|
||||
switch result {
|
||||
case let .success(copy):
|
||||
UIPasteboard.general.string = copy.value
|
||||
copiedValue = copy.value
|
||||
flashCopied()
|
||||
clipboardTask?.cancel()
|
||||
clipboardTask = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(copy.timeoutSeconds))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if UIPasteboard.general.string == copy.value {
|
||||
UIPasteboard.general.items = []
|
||||
}
|
||||
self.copiedValue = nil
|
||||
}
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func watchSwitchChanged(_ sender: UISwitch) {
|
||||
let requested = sender.isOn
|
||||
sender.isEnabled = false
|
||||
loadTask?.cancel()
|
||||
let authentication = authentication
|
||||
let path = detail.path
|
||||
loadTask = Task { [weak self, weak sender] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
return Result<MobileTotpDetail, AuthenticationFailure>.success(
|
||||
try authentication.setTotpWatchShared(
|
||||
path: path,
|
||||
shared: requested,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
loadTask = nil
|
||||
sender?.isEnabled = true
|
||||
switch result {
|
||||
case let .success(detail):
|
||||
apply(detail)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageWatchSnapshotDidChange,
|
||||
object: nil
|
||||
)
|
||||
case let .failure(failure):
|
||||
sender?.setOn(!requested, animated: true)
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureHeader() {
|
||||
issuerLabel.font = .preferredFont(forTextStyle: .title2)
|
||||
issuerLabel.adjustsFontForContentSizeCategory = true
|
||||
issuerLabel.textAlignment = .center
|
||||
accountLabel.font = .preferredFont(forTextStyle: .body)
|
||||
accountLabel.textColor = .secondaryLabel
|
||||
accountLabel.adjustsFontForContentSizeCategory = true
|
||||
accountLabel.textAlignment = .center
|
||||
accountLabel.numberOfLines = 0
|
||||
codeLabel.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(
|
||||
for: .monospacedDigitSystemFont(ofSize: 48, weight: .semibold)
|
||||
)
|
||||
codeLabel.adjustsFontForContentSizeCategory = true
|
||||
codeLabel.textAlignment = .center
|
||||
codeLabel.minimumScaleFactor = 0.55
|
||||
codeLabel.adjustsFontSizeToFitWidth = true
|
||||
codeLabel.layer.cornerRadius = 12
|
||||
codeLabel.layer.masksToBounds = true
|
||||
codeLabel.isAccessibilityElement = true
|
||||
codeLabel.isUserInteractionEnabled = true
|
||||
codeLabel.accessibilityTraits.insert(.button)
|
||||
codeLabel.accessibilityHint = "Copies the current code"
|
||||
countdownLabel.font = .preferredFont(forTextStyle: .footnote)
|
||||
countdownLabel.textColor = .secondaryLabel
|
||||
countdownLabel.adjustsFontForContentSizeCategory = true
|
||||
countdownLabel.textAlignment = .center
|
||||
let stack = UIStackView(arrangedSubviews: [issuerLabel, accountLabel, codeLabel, progress, countdownLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
codeContainer.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: codeContainer.topAnchor, constant: 20),
|
||||
stack.bottomAnchor.constraint(equalTo: codeContainer.bottomAnchor, constant: -20),
|
||||
])
|
||||
codeContainer.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1)
|
||||
tableView.tableHeaderView = codeContainer
|
||||
codeLabel.addGestureRecognizer(
|
||||
UITapGestureRecognizer(target: self, action: #selector(copyRequested))
|
||||
)
|
||||
}
|
||||
|
||||
private func apply(_ detail: MobileTotpDetail) {
|
||||
self.detail = detail
|
||||
title = detail.issuer ?? detail.account
|
||||
issuerLabel.text = detail.issuer ?? "TOTP"
|
||||
accountLabel.text = detail.account
|
||||
codeLabel.text = groupedCode(detail.code)
|
||||
codeLabel.accessibilityLabel = "Current code \(detail.code.map(String.init).joined(separator: " "))"
|
||||
watchSwitch.setOn(detail.sharedWithWatch, animated: false)
|
||||
tableView.reloadData()
|
||||
updateCountdown()
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timerTask?.cancel()
|
||||
timerTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard let self else { return }
|
||||
if currentUnixSeconds() >= detail.validUntil {
|
||||
refreshCode()
|
||||
} else {
|
||||
updateCountdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCountdown() {
|
||||
let remaining = detail.validUntil.saturatingSubtracting(currentUnixSeconds())
|
||||
let fraction = detail.period == 0 ? 0 : Float(remaining) / Float(detail.period)
|
||||
progress.setProgress(min(max(fraction, 0), 1), animated: true)
|
||||
countdownLabel.text = "\(remaining) seconds remaining"
|
||||
progress.accessibilityLabel = "Code validity"
|
||||
progress.accessibilityValue = countdownLabel.text
|
||||
}
|
||||
|
||||
private func refreshCode() {
|
||||
guard loadTask == nil else { return }
|
||||
let authentication = authentication
|
||||
let path = detail.path
|
||||
loadTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileTotpDetail, AuthenticationFailure>.success(
|
||||
try authentication.totpDetail(
|
||||
path: path,
|
||||
unixSeconds: currentUnixSeconds()
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard let self else { return }
|
||||
loadTask = nil
|
||||
guard !Task.isCancelled else { return }
|
||||
switch result {
|
||||
case let .success(detail): apply(detail)
|
||||
case let .failure(failure): showDetailUnavailable(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flashCopied() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
UIAccessibility.post(notification: .announcement, argument: "TOTP code copied")
|
||||
UIView.animate(withDuration: 0.12, animations: {
|
||||
self.codeLabel.backgroundColor = .systemGreen.withAlphaComponent(0.28)
|
||||
}) { _ in
|
||||
UIView.animate(withDuration: 0.55) { self.codeLabel.backgroundColor = .clear }
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ failure: AuthenticationFailure) {
|
||||
if failure.kind == .expired {
|
||||
lockDetail()
|
||||
}
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
|
||||
private func showDetailUnavailable(_ failure: AuthenticationFailure) {
|
||||
if failure.kind == .expired {
|
||||
lockDetail()
|
||||
} else {
|
||||
timerTask?.cancel()
|
||||
detail.code = ""
|
||||
codeLabel.text = nil
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
watchSwitch.isEnabled = false
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "exclamationmark.triangle")
|
||||
configuration.text = failure.title
|
||||
configuration.secondaryText = failure.detail
|
||||
configuration.button = .plain()
|
||||
configuration.button.title = "Back to TOTP"
|
||||
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
|
||||
private func lockDetail() {
|
||||
timerTask?.cancel()
|
||||
loadTask?.cancel()
|
||||
clipboardTask?.cancel()
|
||||
detail.code = ""
|
||||
codeLabel.text = nil
|
||||
if UIPasteboard.general.string == copiedValue {
|
||||
UIPasteboard.general.items = []
|
||||
}
|
||||
copiedValue = nil
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
watchSwitch.isEnabled = false
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "lock.fill")
|
||||
configuration.text = "TOTP Is Locked"
|
||||
configuration.secondaryText = "The code was removed when IronStorage locked."
|
||||
configuration.button = .plain()
|
||||
configuration.button.title = "Back to TOTP"
|
||||
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
contentUnavailableConfiguration = configuration
|
||||
UIAccessibility.post(notification: .announcement, argument: "TOTP locked")
|
||||
}
|
||||
}
|
||||
|
||||
private func currentUnixSeconds() -> UInt64 {
|
||||
UInt64(max(Date().timeIntervalSince1970, 0))
|
||||
}
|
||||
|
||||
private func groupedCode(_ code: String) -> String {
|
||||
let midpoint = code.index(code.startIndex, offsetBy: code.count / 2)
|
||||
return String(code[..<midpoint]) + " " + String(code[midpoint...])
|
||||
}
|
||||
|
||||
private extension UInt64 {
|
||||
func saturatingSubtracting(_ value: UInt64) -> UInt64 {
|
||||
self > value ? self - value : 0
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
|
||||
fileprivate let shellTab = MobileTab.passwords
|
||||
|
||||
Reference in New Issue
Block a user