700 lines
24 KiB
Swift
700 lines
24 KiB
Swift
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<Void, Never>?
|
|
|
|
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<Void, Never>?
|
|
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<Void, Never>?
|
|
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<Void, OnboardingFailure>.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<Void, Never>?
|
|
private var progressTask: Task<Void, Never>?
|
|
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<WorkResult, OnboardingFailure>.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<WorkResult, OnboardingFailure>) {
|
|
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
|
|
}
|
|
}
|