Show TOTP codes in password details (#75)

This commit is contained in:
2026-08-11 23:40:24 +02:00
parent 9759162eff
commit 2026637a0e
9 changed files with 640 additions and 110 deletions

View File

@@ -621,7 +621,7 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
func entryEditor(editor: UInt64) throws -> MobileEntryEditorPage
func entryPage(path: String) throws -> MobileEntryPage
func entryPage(path: String, unixSeconds: UInt64) throws -> MobileEntryPresentation
func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols: Bool) throws -> MobileEntryEditorPage
@@ -806,12 +806,13 @@ open func entryEditor(editor: UInt64)throws -> MobileEntryEditorPage {
})
}
open func entryPage(path: String)throws -> MobileEntryPage {
return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
open func entryPage(path: String, unixSeconds: UInt64)throws -> MobileEntryPresentation {
return try FfiConverterTypeMobileEntryPresentation_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(
self.uniffiCloneHandle(),
FfiConverterString.lower(path),uniffiCallStatus
FfiConverterString.lower(path),
FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus
)
})
}
@@ -2320,6 +2321,64 @@ public func FfiConverterTypeMobileEntryPage_lower(_ value: MobileEntryPage) -> R
}
public struct MobileEntryPresentation: Equatable, Hashable {
public var page: MobileEntryPage
public var totp: MobileTotpDetail?
public var cacheNotice: String?
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(page: MobileEntryPage, totp: MobileTotpDetail?, cacheNotice: String?) {
self.page = page
self.totp = totp
self.cacheNotice = cacheNotice
}
}
#if compiler(>=6)
extension MobileEntryPresentation: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileEntryPresentation: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryPresentation {
return
try MobileEntryPresentation(
page: FfiConverterTypeMobileEntryPage.read(from: &buf),
totp: FfiConverterOptionTypeMobileTotpDetail.read(from: &buf),
cacheNotice: FfiConverterOptionString.read(from: &buf)
)
}
public static func write(_ value: MobileEntryPresentation, into buf: inout [UInt8]) {
FfiConverterTypeMobileEntryPage.write(value.page, into: &buf)
FfiConverterOptionTypeMobileTotpDetail.write(value.totp, into: &buf)
FfiConverterOptionString.write(value.cacheNotice, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileEntryPresentation_lift(_ buf: RustBuffer) throws -> MobileEntryPresentation {
return try FfiConverterTypeMobileEntryPresentation.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileEntryPresentation_lower(_ value: MobileEntryPresentation) -> RustBuffer {
return FfiConverterTypeMobileEntryPresentation.lower(value)
}
public struct MobileEntrySection: Equatable, Hashable {
public var kind: MobileEntrySectionKind
public var title: String
@@ -6231,6 +6290,30 @@ fileprivate struct FfiConverterOptionTypeMobileKeyTransferKey: FfiConverterRustB
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterOptionTypeMobileTotpDetail: FfiConverterRustBuffer {
typealias SwiftType = MobileTotpDetail?
public static func write(_ value: SwiftType, into buf: inout [UInt8]) {
guard let value = value else {
writeInt(&buf, Int8(0))
return
}
writeInt(&buf, Int8(1))
FfiConverterTypeMobileTotpDetail.write(value, into: &buf)
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType {
switch try readInt(&buf) as Int8 {
case 0: return nil
case 1: return try FfiConverterTypeMobileTotpDetail.read(from: &buf)
default: throw UniffiInternalError.unexpectedOptionalTag
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -6775,7 +6858,7 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_editor() != 65179) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page() != 56594) {
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page() != 9073) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password() != 34289) {

View File

@@ -300,7 +300,7 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_editor(
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, RustCallStatus *_Nonnull out_status
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD

View File

@@ -2150,15 +2150,111 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
}
@MainActor
private final class TotpDetailViewController: UITableViewController {
private let authentication: MobileAuthentication
private var detail: MobileTotpDetail
private let codeContainer = UIView()
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>?
@@ -2354,56 +2450,15 @@ private final class TotpDetailViewController: UITableViewController {
}
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))
)
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
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: " "))"
codeView.apply(detail)
watchSwitch.setOn(detail.sharedWithWatch, animated: false)
tableView.reloadData()
updateCountdown()
@@ -2429,12 +2484,7 @@ private final class TotpDetailViewController: UITableViewController {
}
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
codeView.updateCountdown(detail)
}
private func refreshCode() {
@@ -2467,13 +2517,7 @@ private final class TotpDetailViewController: UITableViewController {
}
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 }
}
codeView.flashCopied()
}
private func handle(_ failure: AuthenticationFailure) {
@@ -2489,7 +2533,7 @@ private final class TotpDetailViewController: UITableViewController {
} else {
timerTask?.cancel()
detail.code = ""
codeLabel.text = nil
codeView.clear()
navigationItem.rightBarButtonItem = nil
watchSwitch.isEnabled = false
var configuration = UIContentUnavailableConfiguration.empty()
@@ -2511,7 +2555,7 @@ private final class TotpDetailViewController: UITableViewController {
loadTask?.cancel()
clipboardTask?.cancel()
detail.code = ""
codeLabel.text = nil
codeView.clear()
if UIPasteboard.general.string == copiedValue {
UIPasteboard.general.items = []
}
@@ -3646,12 +3690,18 @@ private struct PasswordFailure: Error, Sendable {
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?) {
@@ -3685,6 +3735,8 @@ private final class LockedPasswordViewController: UITableViewController {
deinit {
unlockTask?.cancel()
entryTask?.cancel()
totpTask?.cancel()
totpTimerTask?.cancel()
clipboardTask?.cancel()
feedbackTask?.cancel()
NotificationCenter.default.removeObserver(self)
@@ -3695,6 +3747,19 @@ private final class LockedPasswordViewController: UITableViewController {
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
}
@@ -3713,6 +3778,14 @@ private final class LockedPasswordViewController: UITableViewController {
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
@@ -3853,8 +3926,11 @@ private final class LockedPasswordViewController: UITableViewController {
entryTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileEntryPage, AuthenticationFailure>.success(
try authentication.entryPage(path: path)
return Result<MobileEntryPresentation, AuthenticationFailure>.success(
try authentication.entryPage(
path: path,
unixSeconds: currentUnixSeconds()
)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
@@ -3864,11 +3940,14 @@ private final class LockedPasswordViewController: UITableViewController {
}.value
guard !Task.isCancelled, let self else { return }
switch result {
case let .success(page):
self.page = page
title = page.title
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)
@@ -3923,6 +4002,115 @@ private final class LockedPasswordViewController: UITableViewController {
}
}
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()
@@ -4010,8 +4198,18 @@ private final class LockedPasswordViewController: UITableViewController {
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