import UIKit extension Notification.Name { static let ironStorageLocalStoreDidChange = Notification.Name( "de.rfc1437.ironstorage.local-store-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 var navigationControllers: [UINavigationController] = [] private var restoreTask: Task? func makeRootController() -> UIViewController { let shell = mobileShellFixture(state: .loading) navigationControllers = shell.pages.map { page in let root: UIViewController = page.tab == .passwords ? PasswordDirectoryViewController(shellPage: page) : 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() 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 } } } } @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 PasswordDirectoryViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab = MobileTab.passwords private let path: String? private var shellPage: MobilePage? private var directoryPage: MobilePasswordPage? private var loadTask: Task? private var loadGeneration = 0 init(shellPage: MobilePage, path: String? = nil) { self.shellPage = shellPage 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) { self.path = path 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) case .entry: LockedPasswordViewController(entry: row) } 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 init(entry: MobilePasswordRow) { self.entry = entry 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 var configuration = UIContentUnavailableConfiguration.empty() configuration.image = UIImage(systemName: "lock.fill") configuration.text = "Locked Password" configuration.secondaryText = "Authenticate to reveal this password entry." contentUnavailableConfiguration = configuration view.accessibilityIdentifier = entry.id } } 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 } }