import UIKit 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" ) } @main final class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private var context: AppContext? 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 } } @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 .preferences: PreferencesViewController(page: page, authentication: authentication) default: ShellViewController(page: page) } let navigation = UINavigationController(rootViewController: root) navigation.navigationBar.prefersLargeTitles = true navigation.tabBarItem = UITabBarItem( title: page.title, image: UIImage(systemName: page.systemImage), selectedImage: UIImage(systemName: page.selectedSystemImage) ) return navigation } tabs.viewControllers = navigationControllers tabs.delegate = self restoreSelectedTab() monitorAuthentication() return tabs } func tabBarController( _ tabBarController: UITabBarController, didSelect viewController: UIViewController ) { guard let navigation = viewController as? UINavigationController, let root = navigation.viewControllers.first as? MobileTabRoot else { return } do { try setSelectedMobileTab(tab: root.shellTab) } catch { let alert = UIAlertController( title: "Selection Was Not Saved", message: error.localizedDescription, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default)) tabs.present(alert, animated: true) } } private func restoreSelectedTab() { restoreTask?.cancel() restoreTask = Task { [weak self] in let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value guard !Task.isCancelled, let self else { return } if let index = shell.pages.firstIndex(where: { $0.tab == shell.selectedTab }) { tabs.selectedIndex = index } } } private func monitorAuthentication() { authenticationMonitor?.cancel() guard let authentication else { return } authenticationMonitor = Task { var wasUnlocked = (try? authentication.state().unlocked) ?? false while !Task.isCancelled { do { try await Task.sleep(for: .seconds(1)) } catch { return } let isUnlocked = (try? authentication.state().unlocked) ?? false if isUnlocked != wasUnlocked { wasUnlocked = isUnlocked NotificationCenter.default.post( name: .ironStorageAuthenticationDidChange, object: authentication ) } } } } } @MainActor private final class ShellViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab: MobileTab private var page: MobilePage private var loadTask: Task? 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 refresh case pull } init(page: MobilePage) { shellTab = page.tab self.page = page super.init(style: .insetGrouped) title = page.title navigationItem.largeTitleDisplayMode = .always refreshControl = UIRefreshControl() refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) NotificationCenter.default.addObserver( self, selector: #selector(localStoreDidChange), name: .ironStorageLocalStoreDidChange, object: nil ) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { loadTask?.cancel() homeOperation?.cancel() homeTask?.cancel() homeProgressTask?.cancel() NotificationCenter.default.removeObserver(self) } override func viewDidLoad() { super.viewDidLoad() apply(page) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) reloadShell() if shellTab == .home, page.state == .ready, homePage != nil { runHome(.refreshIfStale) } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) loadTask?.cancel() cancelHomeWork() } override func numberOfSections(in tableView: UITableView) -> Int { guard page.state == .ready else { return 0 } if shellTab == .home { return homeSections.count } return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { guard shellTab == .home else { return 1 } switch homeSections[section] { case .summary: return homePage?.summaries.count ?? 0 case .incoming: return homePage?.incoming.count ?? 0 case .outgoing: return homePage?.outgoing.count ?? 0 case .empty, .notice: return 1 } } override func tableView( _ tableView: UITableView, titleForHeaderInSection section: Int ) -> String? { guard shellTab == .home else { return nil } switch homeSections[section] { case .summary: return "Status" case .incoming: return "Incoming Activity" case .outgoing: return "Outgoing Activity" case .empty: return "Remote Activity" case .notice: return "Result" } } override func tableView( _ tableView: UITableView, titleForFooterInSection section: Int ) -> String? { guard shellTab == .home, let homePage else { return nil } switch homeSections[section] { case .summary: return freshnessDescription(homePage) case .incoming where homePage.incomingTotal > UInt32(homePage.incoming.count): return "Showing \(homePage.incoming.count) of \(homePage.incomingTotal) incoming commits." case .outgoing where homePage.outgoingTotal > UInt32(homePage.outgoing.count): return "Showing \(homePage.outgoing.count) of \(homePage.outgoingTotal) outgoing commits." default: return nil } } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { if shellTab == .home { return homeCell(at: indexPath) } let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() content.image = UIImage(systemName: page.systemImage) content.text = page.stateTitle content.secondaryText = page.stateDetail content.secondaryTextProperties.numberOfLines = 0 cell.contentConfiguration = content cell.selectionStyle = .none cell.accessibilityLabel = "\(page.stateTitle). \(page.stateDetail)" return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard shellTab == .home, let homePage else { return } let section = homeSections[indexPath.section] let commit: MobileHomeCommit let incoming: Bool switch section { case .incoming: commit = homePage.incoming[indexPath.row] incoming = true case .outgoing: commit = homePage.outgoing[indexPath.row] incoming = false default: return } navigationController?.pushViewController( MobileCommitActivityViewController(commit: commit, incoming: incoming), animated: true ) } @objc private func refreshRequested() { if shellTab == .home, page.state == .ready { guard !isHomeWorking else { refreshControl?.endRefreshing() return } runHome(.pull) } else { reloadShell() } } @objc private func localStoreDidChange() { guard shellTab != .home else { return } reloadShell() } private func reloadShell() { loadGeneration += 1 let generation = loadGeneration loadTask?.cancel() loadTask = Task { [weak self] in let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value guard !Task.isCancelled, let self, generation == loadGeneration, let page = shell.pages.first(where: { $0.tab == self.shellTab }) else { return } apply(page) } } private func apply(_ page: MobilePage) { self.page = page title = page.title if page.state == .empty, shellTab == .home { navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Set Up", style: .done, target: self, action: #selector(setupRequested) ) } else if page.state == .ready, shellTab == .home { navigationItem.rightBarButtonItem = UIBarButtonItem( image: UIImage(systemName: "arrow.clockwise"), style: .plain, target: self, action: #selector(statusRefreshRequested) ) navigationItem.rightBarButtonItem?.accessibilityLabel = "Refresh remote status" navigationItem.rightBarButtonItem?.isEnabled = !isHomeWorking } else if page.state == .ready, shellTab == .preferences { navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Update Token", style: .plain, target: self, action: #selector(tokenUpdateRequested) ) } else { navigationItem.rightBarButtonItem = nil } refreshControl?.endRefreshing() tableView.reloadData() guard page.state != .ready else { contentUnavailableConfiguration = nil if shellTab == .home { loadHomeIfNeeded() } return } var configuration = page.state == .loading ? UIContentUnavailableConfiguration.loading() : UIContentUnavailableConfiguration.empty() configuration.text = page.stateTitle configuration.secondaryText = page.stateDetail configuration.image = UIImage(systemName: stateImage(page.state)) contentUnavailableConfiguration = configuration } @objc private func setupRequested() { navigationController?.pushViewController(OnboardingViewController(), animated: true) } @objc private func tokenUpdateRequested() { navigationController?.pushViewController(TokenUpdateViewController(), animated: true) } @objc private func statusRefreshRequested() { runHome(.refresh) } private var homeSections: [HomeSection] { guard let homePage else { return [] } var sections: [HomeSection] = [.summary] if !homePage.incoming.isEmpty { sections.append(.incoming) } if !homePage.outgoing.isEmpty { sections.append(.outgoing) } if homePage.incomingTotal == 0, homePage.outgoingTotal == 0 { sections.append(.empty) } if homePage.notice != nil { sections.append(.notice) } return sections } private func homeCell(at indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) guard let homePage else { return cell } var content = cell.defaultContentConfiguration() switch homeSections[indexPath.section] { case .summary: let row = homePage.summaries[indexPath.row] content.image = UIImage(systemName: row.systemImage) content.text = row.title content.secondaryText = row.detail cell.selectionStyle = .none case .incoming: configureCommit( homePage.incoming[indexPath.row], content: &content, cell: cell ) case .outgoing: configureCommit( homePage.outgoing[indexPath.row], content: &content, cell: cell ) case .empty: content.image = UIImage(systemName: "checkmark.circle") content.text = "No Remote Activity" content.secondaryText = "There are no commits to pull or push." cell.selectionStyle = .none case .notice: if let notice = homePage.notice { content.image = UIImage(systemName: notice.systemImage) content.text = notice.title content.secondaryText = notice.detail } content.secondaryTextProperties.numberOfLines = 0 cell.selectionStyle = .none } content.secondaryTextProperties.numberOfLines = 0 cell.contentConfiguration = content return cell } private func configureCommit( _ commit: MobileHomeCommit, content: inout UIListContentConfiguration, cell: UITableViewCell ) { content.image = UIImage(systemName: commit.systemImage) content.text = commit.title content.secondaryText = "\(commit.detail)\n\(formattedDate(commit.timestamp))" content.secondaryTextProperties.numberOfLines = 0 cell.accessoryType = .disclosureIndicator cell.accessibilityHint = "Shows password-store changes in this commit." } private func freshnessDescription(_ page: MobileHomePage) -> String { let refreshed = page.refreshedAt.map(formattedDate) switch page.freshness { case .neverRefreshed: return "Not refreshed. Pull down to update the local clone or tap Refresh Status." case .cached: return "Cached · Last refreshed \(refreshed ?? "at an unknown time"). This is not live status." case .current: return "Current · Refreshed \(refreshed ?? "just now")." } } private func formattedDate(_ timestamp: Int64) -> String { Date(timeIntervalSince1970: TimeInterval(timestamp)).formatted( date: .abbreviated, time: .shortened ) } private func loadHomeIfNeeded() { guard homePage == nil, homeTask == nil, !isHomeWorking else { return } homeGeneration += 1 let current = homeGeneration let operation = mobileHomeOperation() homeOperation = operation isHomeWorking = true navigationItem.rightBarButtonItem?.isEnabled = false showHomeLoading() homeTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { return Result.success(try operation.cached()) } catch let error as MobileHomeFfiError { return .failure(HomeFailure(error)) } catch { return .failure(.unexpected) } }.value guard let self, current == homeGeneration else { return } finishHome(result, request: nil) if case .success = result { runHome(.refreshIfStale) } } } private func runHome(_ request: HomeRequest) { guard page.state == .ready, shellTab == .home, !isHomeWorking else { return } cancelHomeWork() homeGeneration += 1 let current = homeGeneration let operation = mobileHomeOperation() homeOperation = operation isHomeWorking = true navigationItem.rightBarButtonItem?.isEnabled = false showHomeProgress(operation.progress()) homeProgressTask = Task { [weak self] in while !Task.isCancelled { do { try await Task.sleep(for: .milliseconds(150)) } catch { return } guard !Task.isCancelled, let self, current == homeGeneration else { return } showHomeProgress(operation.progress()) } } homeTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { let page: MobileHomePage = switch request { case .refreshIfStale: try operation.refreshIfStale() case .refresh: try operation.refresh() case .pull: try operation.pull() } return Result.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 refreshControl?.endRefreshing() switch result { case let .success(homePage): self.homePage = homePage contentUnavailableConfiguration = nil tableView.reloadData() if request == .pull { NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil) if let notice = homePage.notice { UIAccessibility.post(notification: .announcement, argument: notice.title) } } case let .failure(failure): tableView.reloadData() if homePage == nil { showHomeFailure(failure) } else if failure.kind != .interrupted { presentHomeFailure(failure) } } } private func showHomeLoading() { var configuration = UIContentUnavailableConfiguration.loading() configuration.text = "Loading Activity" configuration.secondaryText = "Reading cached remote-branch state from storage." contentUnavailableConfiguration = configuration } private func showHomeFailure(_ failure: HomeFailure) { var configuration = UIContentUnavailableConfiguration.empty() configuration.image = UIImage(systemName: "exclamationmark.triangle") configuration.text = failure.title configuration.secondaryText = failure.detail contentUnavailableConfiguration = configuration } private func presentHomeFailure(_ failure: HomeFailure) { let alert = UIAlertController( title: failure.title, message: failure.detail, preferredStyle: .alert ) if failure.kind == .authentication || failure.kind == .secureStorage { alert.addAction(UIAlertAction(title: "Preferences", style: .default) { [weak self] _ in self?.tabBarController?.selectedIndex = 3 }) } alert.addAction(UIAlertAction(title: "OK", style: .cancel)) present(alert, animated: true) } private func showHomeProgress(_ progress: MobileHomeProgress) { let spinner = UIActivityIndicatorView(style: .medium) spinner.startAnimating() spinner.accessibilityLabel = "In progress" let label = UILabel() label.text = progress.title label.font = .preferredFont(forTextStyle: .headline) label.adjustsFontForContentSizeCategory = true let stack = UIStackView(arrangedSubviews: [spinner, label]) stack.spacing = 8 navigationItem.titleView = stack navigationItem.prompt = progress.detail } private func cancelHomeWork() { homeGeneration += 1 homeOperation?.cancel() homeOperation = nil homeTask?.cancel() homeTask = nil homeProgressTask?.cancel() homeProgressTask = nil isHomeWorking = false navigationItem.titleView = nil navigationItem.prompt = nil refreshControl?.endRefreshing() navigationItem.rightBarButtonItem?.isEnabled = true } private func stateImage(_ state: MobileShellState) -> String { switch state { case .loading: "hourglass" case .empty: "lock.shield" case .ready: page.systemImage case .locked: "lock.fill" case .error: "exclamationmark.triangle" } } } @MainActor private final class PreferencesViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab = MobileTab.preferences private var page: MobilePage private let authentication: MobileAuthentication? private var state: MobileAuthenticationState? 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 ? 2 : 0 } override func tableView( _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { section == 0 ? 1 : 2 } override func tableView( _ tableView: UITableView, titleForHeaderInSection section: Int ) -> String? { section == 0 ? "Secure Unlock" : "Authentication Session" } override func tableView( _ tableView: UITableView, titleForFooterInSection section: Int ) -> String? { if section == 0 { 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: "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) guard indexPath.section == 1, 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) } @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() tableView.reloadData() } } @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 loadGeneration = 0 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() 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 cell.accessoryType = .disclosureIndicator cell.accessibilityLabel = "\(row.title), \(row.detail)" cell.accessibilityHint = row.kind == .directory ? "Opens this password folder." : "Opens the locked password viewer." return cell } 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() } 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 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 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): 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: UIViewController { private let entry: MobilePasswordRow private let authentication: MobileAuthentication? private let imageView = UIImageView() private let titleLabel = UILabel() private let detailLabel = UILabel() private let unlockButton = UIButton(type: .system) private var unlockTask: Task? private var state: MobileAuthenticationState? init(entry: MobilePasswordRow, authentication: MobileAuthentication?) { self.entry = entry self.authentication = authentication super.init(nibName: nil, bundle: nil) title = entry.title navigationItem.largeTitleDisplayMode = .never } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemGroupedBackground imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 44) imageView.tintColor = .secondaryLabel titleLabel.font = .preferredFont(forTextStyle: .title2) titleLabel.adjustsFontForContentSizeCategory = true titleLabel.textAlignment = .center detailLabel.font = .preferredFont(forTextStyle: .body) detailLabel.adjustsFontForContentSizeCategory = true detailLabel.textAlignment = .center detailLabel.textColor = .secondaryLabel detailLabel.numberOfLines = 0 unlockButton.configuration = .filled() unlockButton.configuration?.cornerStyle = .capsule unlockButton.addTarget(self, action: #selector(unlockRequested), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, detailLabel, unlockButton]) stack.axis = .vertical stack.alignment = .center stack.spacing = 12 stack.setCustomSpacing(24, after: detailLabel) stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor), detailLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 360), unlockButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140), ]) view.accessibilityIdentifier = entry.id NotificationCenter.default.addObserver( self, selector: #selector(authenticationDidChange), name: .ironStorageAuthenticationDidChange, object: nil ) render() } deinit { unlockTask?.cancel() NotificationCenter.default.removeObserver(self) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) refreshState() } @objc private func authenticationDidChange() { refreshState() } @objc private func unlockRequested() { if state?.unlocked == true, let authentication { do { try authentication.touchUserActivity() refreshState() UIAccessibility.post(notification: .announcement, argument: "Unlock extended") } catch let error as MobileAuthenticationFfiError { presentAuthenticationFailure(AuthenticationFailure(error)) } catch { presentAuthenticationFailure(.unexpected) } return } 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 ) render() } catch let error as MobileAuthenticationFfiError { presentAuthenticationFailure(AuthenticationFailure(error)) } catch { presentAuthenticationFailure(.unexpected) } } private func refreshState() { state = try? authentication?.state() render() } private func unlock(passphrase: String?) { guard let authentication else { presentAuthenticationFailure(.unavailable) return } unlockTask?.cancel() unlockButton.isEnabled = false unlockButton.configuration?.showsActivityIndicator = true 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 } unlockButton.isEnabled = true unlockButton.configuration?.showsActivityIndicator = false switch result { case let .success(state): self.state = state NotificationCenter.default.post( name: .ironStorageAuthenticationDidChange, object: authentication ) render() 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) } } } } 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 render() { let unlocked = state?.unlocked == true imageView.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill") titleLabel.text = unlocked ? "Key Unlocked" : "Locked Password" detailLabel.text = if unlocked { "The GPG key is available for protected operations for up to \(state?.remainingSeconds ?? 0) seconds." } else { "Authenticate to unlock this password entry. Browsing remains available while locked." } unlockButton.configuration?.title = unlocked ? "Extend Unlock" : "Unlock" navigationItem.rightBarButtonItem = unlocked ? UIBarButtonItem( image: UIImage(systemName: "lock.fill"), style: .plain, target: self, action: #selector(lockRequested) ) : nil navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage" unlockButton.accessibilityHint = unlocked ? "Extends access after authentication." : "Requests biometric authentication or the GPG key passphrase." } } 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." ) private init(kind: MobileAuthenticationErrorKind, title: String, detail: String) { self.kind = kind self.title = title self.detail = detail } } @MainActor private extension UIViewController { func presentAuthenticationFailure(_ failure: AuthenticationFailure) { let alert = UIAlertController( title: failure.title, message: failure.detail, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "OK", style: .default)) present(alert, animated: true) } } private struct HomeFailure: Error, Sendable { let kind: MobileHomeErrorKind let title: String let detail: String init(_ error: MobileHomeFfiError) { switch error { case let .Failed(kind, title, detail): self.kind = kind self.title = title self.detail = detail } } static let unexpected = HomeFailure( kind: .repository, title: "Remote Activity Failed", detail: "IronStorage could not load the storage-provided Home page." ) private init(kind: MobileHomeErrorKind, title: String, detail: String) { self.kind = kind self.title = title self.detail = detail } } @MainActor private final class MobileCommitActivityViewController: UITableViewController { private let commit: MobileHomeCommit init(commit: MobileHomeCommit, incoming: Bool) { self.commit = commit super.init(style: .insetGrouped) title = incoming ? "Incoming Commit" : "Outgoing Commit" navigationItem.largeTitleDisplayMode = .never } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func numberOfSections(in tableView: UITableView) -> Int { 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { section == 0 ? 1 : max(commit.changes.count, 1) } override func tableView( _ tableView: UITableView, titleForHeaderInSection section: Int ) -> String? { section == 0 ? "Commit" : "Password Store Changes" } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() if indexPath.section == 0 { content.image = UIImage(systemName: commit.systemImage) content.text = commit.title content.secondaryText = "\(commit.detail)\n\(formattedDate)" } else if commit.changes.isEmpty { content.image = UIImage(systemName: "minus.circle") content.text = "No File Changes" content.secondaryText = "This commit does not change password-store files." } else { let change = commit.changes[indexPath.row] content.image = UIImage(systemName: change.systemImage) content.text = change.title content.secondaryText = change.detail } content.secondaryTextProperties.numberOfLines = 0 cell.contentConfiguration = content cell.selectionStyle = .none return cell } private var formattedDate: String { Date(timeIntervalSince1970: TimeInterval(commit.timestamp)).formatted( date: .abbreviated, time: .shortened ) } } @MainActor private final class TokenUpdateViewController: UITableViewController { private let accountField = UITextField() private let tokenField = UITextField() private var updateTask: Task? 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 } }