Files
IronStorage/apple/Sources/App/IronStorageApp.swift

1468 lines
52 KiB
Swift

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<Void, Never>?
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<Void, Never>?
private var loadGeneration = 0
private var homePage: MobileHomePage?
private var homeTask: Task<Void, Never>?
private var homeProgressTask: Task<Void, Never>?
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<MobileHomePage, HomeFailure>.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<MobileHomePage, HomeFailure>.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<MobileHomePage, HomeFailure>,
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<Void, Never>?
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<MobilePasswordPage, PasswordFailure>.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<MobilePasswordPage, PasswordFailure>,
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<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
}
}