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? private var authenticationMonitor: Task? 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) case .home: ShellViewController(page: page, authentication: authentication) } let navigation = UINavigationController(rootViewController: root) navigation.navigationBar.prefersLargeTitles = true navigation.tabBarItem = UITabBarItem( title: page.title, image: UIImage(systemName: page.systemImage), selectedImage: UIImage(systemName: page.selectedSystemImage) ) return navigation } tabs.viewControllers = navigationControllers tabs.delegate = self restoreSelectedTab() monitorAuthentication() return tabs } func lockForBackground() { guard let authentication else { return } try? authentication.manualLock() NotificationCenter.default.post( name: .ironStorageAuthenticationDidChange, object: authentication ) } func tabBarController( _ tabBarController: UITabBarController, didSelect viewController: UIViewController ) { guard let navigation = viewController as? UINavigationController, let root = navigation.viewControllers.first as? MobileTabRoot else { return } do { try setSelectedMobileTab(tab: root.shellTab) } catch { let alert = UIAlertController( title: "Selection Was Not Saved", message: error.localizedDescription, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default)) tabs.present(alert, animated: true) } } private func restoreSelectedTab() { restoreTask?.cancel() restoreTask = Task { [weak self] in let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value guard !Task.isCancelled, let self else { return } if let index = shell.pages.firstIndex(where: { $0.tab == shell.selectedTab }) { tabs.selectedIndex = index } } } private func monitorAuthentication() { authenticationMonitor?.cancel() guard let authentication else { return } authenticationMonitor = Task { var wasUnlocked = (try? authentication.state().unlocked) ?? false while !Task.isCancelled { do { try await Task.sleep(for: .seconds(1)) } catch { return } let isUnlocked = (try? authentication.state().unlocked) ?? false if isUnlocked != wasUnlocked { wasUnlocked = isUnlocked NotificationCenter.default.post( name: .ironStorageAuthenticationDidChange, object: authentication ) } } } } } @MainActor private final class ShellViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab: MobileTab private let authentication: MobileAuthentication? private var page: MobilePage private var loadTask: Task? private var loadGeneration = 0 private var homePage: MobileHomePage? private var homeTask: Task? private var homeProgressTask: Task? private var homeOperation: MobileHomeOperation? private var homeGeneration = 0 private var isHomeWorking = false private enum HomeSection { case summary case incoming case outgoing case empty case notice } private enum HomeRequest: Equatable { case refreshIfStale case commit(String) case fetch case pull case push var changesLocalStore: Bool { if case .pull = self { return true } return false } } init(page: MobilePage, authentication: MobileAuthentication?) { shellTab = page.tab self.authentication = authentication self.page = page super.init(style: .insetGrouped) title = page.title navigationItem.largeTitleDisplayMode = .always refreshControl = UIRefreshControl() refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) NotificationCenter.default.addObserver( self, selector: #selector(localStoreDidChange), name: .ironStorageLocalStoreDidChange, object: nil ) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { loadTask?.cancel() homeOperation?.cancel() homeTask?.cancel() homeProgressTask?.cancel() NotificationCenter.default.removeObserver(self) } override func viewDidLoad() { super.viewDidLoad() apply(page) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) reloadShell() if shellTab == .home, page.state == .ready, homePage != nil { runHome(.refreshIfStale) } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) loadTask?.cancel() cancelHomeWork() } override func numberOfSections(in tableView: UITableView) -> Int { guard page.state == .ready else { return 0 } if shellTab == .home { return homeSections.count } return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { guard shellTab == .home else { return 1 } switch homeSections[section] { case .summary: return homePage?.summaries.count ?? 0 case .incoming: return homePage?.incoming.count ?? 0 case .outgoing: return homePage?.outgoing.count ?? 0 case .empty, .notice: return 1 } } override func tableView( _ tableView: UITableView, titleForHeaderInSection section: Int ) -> String? { guard shellTab == .home else { return nil } switch homeSections[section] { case .summary: return "Status" case .incoming: return "Incoming Activity" case .outgoing: return "Outgoing Activity" case .empty: return "Remote Activity" case .notice: return "Result" } } override func tableView( _ tableView: UITableView, titleForFooterInSection section: Int ) -> String? { guard shellTab == .home, let homePage else { return nil } switch homeSections[section] { case .summary: return freshnessDescription(homePage) case .incoming where homePage.incomingTotal > UInt32(homePage.incoming.count): return "Showing \(homePage.incoming.count) of \(homePage.incomingTotal) incoming commits." case .outgoing where homePage.outgoingTotal > UInt32(homePage.outgoing.count): return "Showing \(homePage.outgoing.count) of \(homePage.outgoingTotal) outgoing commits." default: return nil } } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { if shellTab == .home { return homeCell(at: indexPath) } let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() content.image = UIImage(systemName: page.systemImage) content.text = page.stateTitle content.secondaryText = page.stateDetail content.secondaryTextProperties.numberOfLines = 0 cell.contentConfiguration = content cell.selectionStyle = .none cell.accessibilityLabel = "\(page.stateTitle). \(page.stateDetail)" return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) guard shellTab == .home, let homePage else { return } let section = homeSections[indexPath.section] let commit: MobileHomeCommit let incoming: Bool switch section { case .incoming: commit = homePage.incoming[indexPath.row] incoming = true case .outgoing: commit = homePage.outgoing[indexPath.row] incoming = false default: return } navigationController?.pushViewController( MobileCommitActivityViewController(commit: commit, incoming: incoming), animated: true ) } override func tableView( _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath ) -> UISwipeActionsConfiguration? { let actions = homeActions(at: indexPath).map { action in let contextual = UIContextualAction(style: .normal, title: action.title) { [weak self] _, _, completion in self?.perform(action) completion(true) } contextual.image = UIImage(systemName: action.systemImage) contextual.backgroundColor = actionColor(action.kind) return contextual } guard !actions.isEmpty else { return nil } let configuration = UISwipeActionsConfiguration(actions: actions) configuration.performsFirstActionWithFullSwipe = actions.count == 1 return configuration } override func tableView( _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint ) -> UIContextMenuConfiguration? { let actions = homeActions(at: indexPath) guard !actions.isEmpty else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in UIMenu(children: actions.map { action in UIAction( title: action.title, image: UIImage(systemName: action.systemImage) ) { [weak self] _ in self?.perform(action) } }) } } @objc private func refreshRequested() { if shellTab == .home, page.state == .ready { guard !isHomeWorking else { refreshControl?.endRefreshing() return } runHome(.pull) } else { reloadShell() } } @objc private func localStoreDidChange() { guard shellTab != .home else { return } reloadShell() } private func reloadShell() { loadGeneration += 1 let generation = loadGeneration loadTask?.cancel() loadTask = Task { [weak self] in let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value guard !Task.isCancelled, let self, generation == loadGeneration, let page = shell.pages.first(where: { $0.tab == self.shellTab }) else { return } apply(page) } } private func apply(_ page: MobilePage) { self.page = page title = page.title if page.state == .empty, shellTab == .home { navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Set Up", style: .done, target: self, action: #selector(setupRequested) ) } else if page.state == .ready, shellTab == .home { navigationItem.rightBarButtonItem = UIBarButtonItem( image: UIImage(systemName: "arrow.clockwise"), style: .plain, target: self, action: #selector(statusRefreshRequested) ) navigationItem.rightBarButtonItem?.accessibilityLabel = "Refresh remote status" navigationItem.rightBarButtonItem?.isEnabled = !isHomeWorking } else 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(.fetch) } @objc private func cancelHomeRequested() { homeOperation?.cancel() navigationItem.leftBarButtonItem?.isEnabled = false navigationItem.prompt = "Cancelling the current Git action…" } private var homeSections: [HomeSection] { guard let homePage else { return [] } var sections: [HomeSection] = [.summary] if !homePage.incoming.isEmpty { sections.append(.incoming) } if !homePage.outgoing.isEmpty { sections.append(.outgoing) } if homePage.incomingTotal == 0, homePage.outgoingTotal == 0 { sections.append(.empty) } if homePage.notice != nil { sections.append(.notice) } return sections } private func homeCell(at indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) guard let homePage else { return cell } var content = cell.defaultContentConfiguration() switch homeSections[indexPath.section] { case .summary: let row = homePage.summaries[indexPath.row] content.image = UIImage(systemName: row.systemImage) content.text = row.title content.secondaryText = row.detail cell.selectionStyle = .none cell.accessibilityCustomActions = accessibilityActions(row.actions) case .incoming: configureCommit( homePage.incoming[indexPath.row], content: &content, cell: cell ) cell.accessibilityCustomActions = accessibilityActions( homePage.incoming[indexPath.row].actions ) case .outgoing: configureCommit( homePage.outgoing[indexPath.row], content: &content, cell: cell ) cell.accessibilityCustomActions = accessibilityActions( homePage.outgoing[indexPath.row].actions ) case .empty: content.image = UIImage(systemName: "checkmark.circle") content.text = "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 guard let authentication else { showHomeFailure(.unavailable) return } let operation = mobileHomeOperation(authentication: authentication) homeOperation = operation isHomeWorking = true navigationItem.rightBarButtonItem?.isEnabled = false showHomeLoading() homeTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { return Result.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 } guard let authentication else { showHomeFailure(.unavailable) return } cancelHomeWork() homeGeneration += 1 let current = homeGeneration let operation = mobileHomeOperation(authentication: authentication) homeOperation = operation isHomeWorking = true navigationItem.rightBarButtonItem?.isEnabled = false showHomeProgress(operation.progress()) homeProgressTask = Task { [weak self] in while !Task.isCancelled { do { try await Task.sleep(for: .milliseconds(150)) } catch { return } guard !Task.isCancelled, let self, current == homeGeneration else { return } showHomeProgress(operation.progress()) } } homeTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { let page: MobileHomePage = switch request { case .refreshIfStale: try operation.refreshIfStale() case let .commit(message): try operation.commit(message: message) case .fetch: try operation.fetch() case .pull: try operation.pull() case .push: try operation.push() } return Result.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, request: HomeRequest? ) { homeProgressTask?.cancel() homeProgressTask = nil homeTask = nil homeOperation = nil isHomeWorking = false navigationItem.titleView = nil navigationItem.prompt = nil navigationItem.rightBarButtonItem?.isEnabled = true navigationItem.leftBarButtonItem = nil refreshControl?.endRefreshing() switch result { case let .success(homePage): self.homePage = homePage contentUnavailableConfiguration = nil tableView.reloadData() if request?.changesLocalStore == true { NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil) NotificationCenter.default.post( name: .ironStorageWatchSnapshotDidChange, object: nil ) } if let notice = homePage.notice { UIAccessibility.post(notification: .announcement, argument: notice.title) } case let .failure(failure): tableView.reloadData() if homePage == nil { showHomeFailure(failure) presentHomeFailure(failure, retry: request ?? .fetch) } else if failure.kind == .interrupted { UIAccessibility.post(notification: .announcement, argument: failure.title) } else { presentHomeFailure(failure, retry: request ?? .fetch) } } } private func showHomeLoading() { var configuration = UIContentUnavailableConfiguration.loading() configuration.text = "Loading Activity" configuration.secondaryText = "Reading 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, retry: HomeRequest) { let alert = UIAlertController( title: failure.title, message: failure.detail, preferredStyle: .alert ) if failure.kind == .authentication || failure.kind == .secureStorage { alert.addAction(UIAlertAction(title: "Preferences", style: .default) { [weak self] _ in guard let tabs = self?.tabBarController else { return } tabs.selectedIndex = tabs.viewControllers?.firstIndex { controller in guard let navigation = controller as? UINavigationController else { return false } return (navigation.viewControllers.first as? MobileTabRoot)?.shellTab == .preferences } ?? tabs.selectedIndex }) } alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in self?.runHome(retry) }) alert.addAction(UIAlertAction(title: "OK", style: .cancel)) present(alert, animated: true) } private func showHomeProgress(_ progress: MobileHomeProgress) { let spinner = UIActivityIndicatorView(style: .medium) spinner.startAnimating() spinner.accessibilityLabel = "In progress" let label = UILabel() label.text = progress.title label.font = .preferredFont(forTextStyle: .headline) label.adjustsFontForContentSizeCategory = true let stack = UIStackView(arrangedSubviews: [spinner, label]) stack.spacing = 8 navigationItem.titleView = stack navigationItem.prompt = progress.detail navigationItem.leftBarButtonItem = UIBarButtonItem( title: "Cancel", style: .plain, target: self, action: #selector(cancelHomeRequested) ) } private func cancelHomeWork() { homeGeneration += 1 homeOperation?.cancel() homeOperation = nil homeTask?.cancel() homeTask = nil homeProgressTask?.cancel() homeProgressTask = nil isHomeWorking = false navigationItem.titleView = nil navigationItem.prompt = nil navigationItem.leftBarButtonItem = nil refreshControl?.endRefreshing() navigationItem.rightBarButtonItem?.isEnabled = true } private func homeActions(at indexPath: IndexPath) -> [MobileHomeAction] { guard let homePage, homeSections.indices.contains(indexPath.section) else { return [] } return switch homeSections[indexPath.section] { case .summary where homePage.summaries.indices.contains(indexPath.row): homePage.summaries[indexPath.row].actions case .incoming where homePage.incoming.indices.contains(indexPath.row): homePage.incoming[indexPath.row].actions case .outgoing where homePage.outgoing.indices.contains(indexPath.row): homePage.outgoing[indexPath.row].actions default: [] } } private func perform(_ action: MobileHomeAction) { switch action.kind { case .commit: promptForCommitMessage() case .fetch: runHome(.fetch) case .pull: runHome(.pull) case .push: runHome(.push) } } private func promptForCommitMessage() { let alert = UIAlertController( title: "Commit Changes", message: "Enter the message for all current password-store changes.", preferredStyle: .alert ) alert.addTextField { field in field.placeholder = "Commit message" field.autocapitalizationType = .sentences field.returnKeyType = .done } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Commit", style: .default) { [weak self, weak alert] _ in self?.runHome(.commit(alert?.textFields?.first?.text ?? "")) }) present(alert, animated: true) } private func accessibilityActions(_ actions: [MobileHomeAction]) -> [UIAccessibilityCustomAction] { actions.map { action in UIAccessibilityCustomAction(name: action.title) { [weak self] _ in self?.perform(action) return true } } } private func actionColor(_ kind: MobileHomeActionKind) -> UIColor { switch kind { case .commit: .systemOrange case .fetch: .systemGray case .pull: .systemBlue case .push: .systemGreen } } private func stateImage(_ state: MobileShellState) -> String { switch state { case .loading: "hourglass" case .empty: "lock.shield" case .ready: page.systemImage case .locked: "lock.fill" case .error: "exclamationmark.triangle" } } } @MainActor private final class PreferencesViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab = MobileTab.preferences private var page: MobilePage private let authentication: MobileAuthentication? private let keyTransfer = try? mobileKeyTransfer() private var state: MobileAuthenticationState? private var gitIdentity: MobileGitIdentity? private var preferenceTask: Task? private var loadTask: Task? 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 ? 4 : 0 } override func tableView( _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { section == 1 ? 2 : (section == 3 ? 2 : 1) } override func tableView( _ tableView: UITableView, titleForHeaderInSection section: Int ) -> String? { switch section { case 0: "Git Commit Identity" case 1: "GPG Key Transfer" case 2: "Secure Unlock" default: "Authentication Session" } } override func tableView( _ tableView: UITableView, titleForFooterInSection section: Int ) -> String? { if section == 0 { return "Used as the author for commits created by the iPhone app." } if section == 1 { return "Scan or display ASCII-armored GPG keys. Private-key transfers require explicit confirmation and passphrase validation." } if section == 2 { 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 { content.image = UIImage(systemName: "person.crop.circle") content.text = gitIdentity?.name ?? "Git Commit Identity" content.secondaryText = gitIdentity?.email ?? "Set the commit author name and email" cell.accessoryType = .disclosureIndicator cell.isUserInteractionEnabled = authentication != nil cell.contentView.alpha = authentication == nil ? 0.45 : 1 } else if indexPath.section == 1 { 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 == 2 { 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 { editGitIdentity() return } if indexPath.section == 1 { guard let keyTransfer else { return } if indexPath.row == 0 { requestKeyScanner(keyTransfer) } else { navigationController?.pushViewController( KeyExportListViewController(transfer: keyTransfer), animated: true ) } return } guard indexPath.section == 3, 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) } private func editGitIdentity() { guard let authentication else { presentAuthenticationFailure(.unavailable) return } let alert = UIAlertController( title: "Git Commit Identity", message: "Enter the author used for commits created on this iPhone.", preferredStyle: .alert ) alert.addTextField { [gitIdentity] field in field.placeholder = "Name" field.text = gitIdentity?.name field.textContentType = .name field.autocapitalizationType = .words field.clearButtonMode = .whileEditing } alert.addTextField { [gitIdentity] field in field.placeholder = "Email" field.text = gitIdentity?.email field.textContentType = .emailAddress field.keyboardType = .emailAddress field.autocapitalizationType = .none field.autocorrectionType = .no field.clearButtonMode = .whileEditing } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in guard let self, let fields = alert?.textFields, fields.count == 2 else { return } saveGitIdentity( authentication, name: fields[0].text ?? "", email: fields[1].text ?? "" ) }) present(alert, animated: true) } private func saveGitIdentity( _ authentication: MobileAuthentication, name: String, email: String ) { preferenceTask?.cancel() preferenceTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { return Result.success( try authentication.setGitIdentity(name: name, email: email) ) } catch let error as MobileAuthenticationFfiError { return .failure(AuthenticationFailure(error)) } catch { return .failure(.unexpected) } }.value guard !Task.isCancelled, let self else { return } switch result { case let .success(identity): gitIdentity = identity tableView.reloadSections(IndexSet(integer: 0), with: .automatic) UIAccessibility.post( notification: .announcement, argument: "Git commit identity updated" ) case let .failure(failure): presentAuthenticationFailure(failure) } } } @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.success( try authentication.setBiometricUnlock(enabled: enabled) ) } catch let error as MobileAuthenticationFfiError { return .failure(AuthenticationFailure(error)) } catch { return .failure(.unexpected) } }.value guard !Task.isCancelled, let self else { return } sender?.isEnabled = true switch result { case let .success(state): self.state = state tableView.reloadData() NotificationCenter.default.post( name: .ironStorageAuthenticationDidChange, object: authentication ) case let .failure(failure): sender?.setOn(!enabled, animated: true) presentAuthenticationFailure(failure) } } } private func refreshState() { state = try? authentication?.state() gitIdentity = try? authentication?.gitIdentity() 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.. Void) { super.init(frame: .zero) titleLabel.font = .preferredFont(forTextStyle: .headline) titleLabel.adjustsFontForContentSizeCategory = true titleLabel.textAlignment = .center detailLabel.font = .preferredFont(forTextStyle: .subheadline) detailLabel.adjustsFontForContentSizeCategory = true detailLabel.textColor = .secondaryLabel detailLabel.textAlignment = .center detailLabel.numberOfLines = 0 progressView.accessibilityLabel = "TOTP discovery progress" let cancelButton = UIButton(type: .system, primaryAction: UIAction(title: "Cancel") { _ in cancel() }) let progressRow = UIStackView(arrangedSubviews: [spinner, progressView]) progressRow.alignment = .center progressRow.spacing = 12 let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, progressRow, cancelButton]) stack.axis = .vertical stack.alignment = .fill stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: centerXAnchor), stack.topAnchor.constraint(equalTo: topAnchor, constant: 24), stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24), stack.leadingAnchor.constraint(greaterThanOrEqualTo: readableContentGuide.leadingAnchor), stack.trailingAnchor.constraint(lessThanOrEqualTo: readableContentGuide.trailingAnchor), progressView.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } func apply(_ progress: MobileTotpDiscoveryProgress) { titleLabel.text = switch progress.phase { case .preparing: "Preparing TOTP Discovery" case .inspecting: "Discovering TOTP Entries" case .saving: "Saving Protected Cache" case .complete: "TOTP Discovery Complete" case .cancelled: "Cancelling TOTP Discovery" } if progress.total == 0 { spinner.startAnimating() progressView.isHidden = true detailLabel.text = "Reading the password-store inventory." } else { spinner.stopAnimating() progressView.isHidden = false progressView.progress = Float(progress.inspected) / Float(progress.total) progressView.accessibilityValue = "\(progress.inspected) of \(progress.total)" detailLabel.text = "\(progress.inspected) of \(progress.total) inspected • " + "\(progress.cacheHits) cached • \(progress.matches) TOTP" + (progress.unavailable == 0 ? "" : " • \(progress.unavailable) unavailable") } } } @MainActor private final class TotpListViewController: UITableViewController, MobileTabRoot, UISearchResultsUpdating { fileprivate let shellTab = MobileTab.totp private let authentication: MobileAuthentication? private let searchController = UISearchController(searchResultsController: nil) private var shellPage: MobilePage private var page: MobileTotpPage? private var unfilteredPage: MobileTotpPage? private var loadTask: Task? private var searchTask: Task? private var unlockTask: Task? private var shellTask: Task? private var progressTask: Task? private var operation: MobileTotpOperation? private var loadGeneration = 0 private var searchGeneration = 0 private var refreshAfterUnlock = false init(shellPage: MobilePage, authentication: MobileAuthentication?) { self.shellPage = shellPage self.authentication = authentication super.init(style: .insetGrouped) title = shellPage.title navigationItem.largeTitleDisplayMode = .always searchController.searchResultsUpdater = self searchController.obscuresBackgroundDuringPresentation = false searchController.searchBar.placeholder = "TOTP entry names and folders" searchController.searchBar.autocapitalizationType = .none searchController.searchBar.autocorrectionType = .no searchController.searchBar.spellCheckingType = .no navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false definesPresentationContext = true refreshControl = UIRefreshControl() refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { operation?.cancel() loadTask?.cancel() searchTask?.cancel() unlockTask?.cancel() shellTask?.cancel() progressTask?.cancel() NotificationCenter.default.removeObserver(self) } override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver( self, selector: #selector(authenticationDidChange), name: .ironStorageAuthenticationDidChange, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(localStoreDidChange), name: .ironStorageLocalStoreDidChange, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(localStoreDidChange), name: .ironStorageWatchSnapshotDidChange, object: nil ) refreshState() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) reloadShell() } func updateSearchResults(for searchController: UISearchController) { search(searchController.searchBar.text ?? "") } override func numberOfSections(in tableView: UITableView) -> Int { 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { page?.rows.count ?? 0 } override func tableView( _ tableView: UITableView, titleForFooterInSection section: Int ) -> String? { guard let page else { return nil } let unavailable = page.unavailableEntries == 0 ? "" : " \(page.unavailableEntries) entries could not be inspected with the active key." let cache = page.cacheNotice.map { " \($0)" } ?? "" return page.watch.detail + unavailable + cache } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let row = page?.rows[indexPath.row] let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() content.image = UIImage(systemName: "timer") content.text = row?.title content.secondaryText = row?.detail content.secondaryTextProperties.numberOfLines = 2 if row?.sharedWithWatch == true { content.secondaryText = [row?.detail, "Apple Watch selected"] .compactMap { $0 } .joined(separator: " • ") } cell.contentConfiguration = content cell.accessoryType = .disclosureIndicator cell.accessibilityLabel = [row?.title, row?.detail].compactMap { $0 }.joined(separator: ", ") cell.accessibilityValue = row?.sharedWithWatch == true ? "Selected for Apple Watch" : nil return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard let row = page?.rows[indexPath.row] else { return } guard (try? authentication?.state().unlocked) == true else { unlock(passphrase: nil, row: row) return } open(row) } private func open(_ row: MobileTotpRow) { guard let authentication else { return } loadTask?.cancel() loadTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { try authentication.touchUserActivity() return Result.success( try authentication.totpDetail( path: row.path, unixSeconds: currentUnixSeconds() ) ) } catch let error as MobileAuthenticationFfiError { return .failure(AuthenticationFailure(error)) } catch { return .failure(.unexpected) } }.value guard !Task.isCancelled, let self else { return } switch result { case let .success(detail): navigationController?.pushViewController( TotpDetailViewController(authentication: authentication, detail: detail), animated: true ) case let .failure(failure): handle(failure) } } } @objc private func refreshRequested() { if (try? authentication?.state().unlocked) == true { loadPage() } else { refreshAfterUnlock = true refreshControl?.endRefreshing() unlockRequested() } } @objc private func authenticationDidChange() { if (try? authentication?.state().unlocked) != true { operation?.cancel() } refreshState() } @objc private func localStoreDidChange() { operation?.cancel() loadTask?.cancel() searchTask?.cancel() progressTask?.cancel() loadGeneration += 1 searchGeneration += 1 operation = nil progressTask = nil tableView.tableHeaderView = nil page = nil unfilteredPage = nil loadCachedPage() } @objc private func unlockRequested() { unlock(passphrase: nil, row: nil) } @objc private func lockRequested() { guard let authentication else { return } do { try authentication.manualLock() NotificationCenter.default.post( name: .ironStorageAuthenticationDidChange, object: authentication ) } catch let error as MobileAuthenticationFfiError { presentAuthenticationFailure(AuthenticationFailure(error)) } catch { presentAuthenticationFailure(.unexpected) } } private func refreshState(force: Bool = false) { guard shellPage.state == .ready else { searchTask?.cancel() searchGeneration += 1 page = nil unfilteredPage = nil showUnavailable( title: shellPage.stateTitle, detail: shellPage.stateDetail, image: shellPage.systemImage ) return } if force, (try? authentication?.state().unlocked) == true { loadPage() } else if page == nil, loadTask == nil { loadCachedPage() } else if page != nil { configureLockButton() } } private func reloadShell() { shellTask?.cancel() shellTask = Task { [weak self] in let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value guard !Task.isCancelled, let self, let page = shell.pages.first(where: { $0.tab == .totp }) else { return } shellPage = page refreshState() } } private func unlock(passphrase: String?, row: MobileTotpRow?) { guard let authentication else { presentAuthenticationFailure(.unavailable) return } unlockTask?.cancel() showLoading("Unlocking TOTP") unlockTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { let state = if let row { try authentication.unlockEntry(path: row.path, passphrase: passphrase) } else { try authentication.unlockTotp(passphrase: passphrase) } return Result.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.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.success( try authentication.cachedTotpPage() ) } catch let error as MobileAuthenticationFfiError { return .failure(AuthenticationFailure(error)) } catch { return .failure(.unexpected) } }.value guard !Task.isCancelled, let self else { return } loadTask = nil switch result { case let .success(.some(page)): apply(page) case .success(.none): if (try? authentication.state().unlocked) == true { loadPage() } else { showLocked() } case let .failure(failure): handle(failure) } } } private func apply(_ page: MobileTotpPage, remember: Bool = true) { if remember { unfilteredPage = page let query = searchController.searchBar.text ?? "" if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { search(query) return } } self.page = page configureLockButton() tableView.reloadData() if page.rows.isEmpty { let query = searchController.searchBar.text ?? "" let searching = !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty showUnavailable( title: searching ? "No Results" : "No TOTP Codes", detail: searching ? "No cached TOTP entries match “\(query)”." : "No valid time-based OTP entries were found in the password store.", image: searching ? "magnifyingglass" : "timer" ) } else { contentUnavailableConfiguration = nil } } private func search(_ query: String) { searchGeneration += 1 let generation = searchGeneration searchTask?.cancel() guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { if let unfilteredPage { apply(unfilteredPage, remember: false) } return } guard let authentication else { return } searchTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { return Result.success( try authentication.searchCachedTotpPage(query: query) ) } catch let error as MobileAuthenticationFfiError { return .failure(AuthenticationFailure(error)) } catch { return .failure(.unexpected) } }.value guard !Task.isCancelled, let self, generation == searchGeneration else { return } switch result { case let .success(.some(page)): apply(page, remember: false) case .success(.none): break case let .failure(failure): handle(failure) } } } private func configureLockButton() { let unlocked = (try? authentication?.state().unlocked) == true navigationItem.rightBarButtonItem = UIBarButtonItem( image: UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill"), style: .plain, target: self, action: unlocked ? #selector(lockRequested) : #selector(unlockRequested) ) navigationItem.rightBarButtonItem?.accessibilityLabel = unlocked ? "Lock IronStorage" : "Unlock IronStorage" } private func showLocked() { page = nil unfilteredPage = nil tableView.reloadData() navigationItem.rightBarButtonItem = nil var configuration = UIContentUnavailableConfiguration.empty() configuration.image = UIImage(systemName: "lock.fill") configuration.text = "TOTP Is Locked" configuration.secondaryText = "Unlock once to discover time-based one-time-password entries." configuration.button = .filled() configuration.button.title = "Unlock" configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in self?.unlockRequested() } contentUnavailableConfiguration = configuration refreshControl?.endRefreshing() } private func cancelDiscovery() { operation?.cancel() } private func apply(_ progress: MobileTotpDiscoveryProgress, to view: TotpDiscoveryView) { view.apply(progress) let width = tableView.bounds.width let height = view.systemLayoutSizeFitting( CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel ).height view.frame = CGRect(x: 0, y: 0, width: width, height: height) if tableView.tableHeaderView === view { tableView.tableHeaderView = view } } private func handle(_ failure: AuthenticationFailure) { if failure.kind == .expired { NotificationCenter.default.post( name: .ironStorageAuthenticationDidChange, object: authentication ) refreshState() } 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? private var loadTask: Task? private var clipboardTask: Task? 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.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.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.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[.. UInt64 { self > value ? self - value : 0 } } @MainActor private final class PasswordMutationCoordinator { private weak var presenter: UIViewController? private let authentication: MobileAuthentication? private var operationTask: Task? 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.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.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.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? private var shellTask: Task? 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.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, 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? private var createTask: Task? 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.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.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, 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? private var entryTask: Task? private var totpTask: Task? private var totpTimerTask: Task? private var clipboardTask: Task? private var feedbackTask: Task? 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.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.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.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.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.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( _ 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.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? 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? 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.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." ) static let unavailable = HomeFailure( kind: .configuration, title: "Home Is Unavailable", detail: "Complete password-store setup before using Git actions." ) 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? 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.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? private var progressTask: Task? 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.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) { 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 } }