import UIKit @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 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 = 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? ShellViewController 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 { fileprivate let shellTab: MobileTab private var page: MobilePage private var loadTask: Task? private var loadGeneration = 0 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) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { loadTask?.cancel() } override func viewDidLoad() { super.viewDidLoad() apply(page) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) reloadShell() } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) loadTask?.cancel() } override func numberOfSections(in tableView: UITableView) -> Int { page.state == .ready ? 1 : 0 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { 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 } @objc private func refreshRequested() { 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 == .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 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) } 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 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 } }