2408 lines
87 KiB
Swift
2408 lines
87 KiB
Swift
import UIKit
|
|
|
|
extension Notification.Name {
|
|
static let ironStorageLocalStoreDidChange = Notification.Name(
|
|
"de.rfc1437.ironstorage.local-store-did-change"
|
|
)
|
|
static let ironStorageAuthenticationDidChange = Notification.Name(
|
|
"de.rfc1437.ironstorage.authentication-did-change"
|
|
)
|
|
}
|
|
|
|
@main
|
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
var window: UIWindow?
|
|
private var context: AppContext?
|
|
|
|
func application(
|
|
_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
|
) -> Bool {
|
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
let context = AppContext()
|
|
self.context = context
|
|
window.rootViewController = context.makeRootController()
|
|
window.makeKeyAndVisible()
|
|
self.window = window
|
|
return true
|
|
}
|
|
|
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
|
context?.lockForBackground()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private protocol MobileTabRoot: AnyObject {
|
|
var shellTab: MobileTab { get }
|
|
}
|
|
|
|
@MainActor
|
|
private final class AppContext: NSObject, UITabBarControllerDelegate {
|
|
private let tabs = UITabBarController()
|
|
private let authentication = try? mobileAuthentication()
|
|
private var navigationControllers: [UINavigationController] = []
|
|
private var restoreTask: Task<Void, Never>?
|
|
private var authenticationMonitor: Task<Void, Never>?
|
|
|
|
deinit {
|
|
restoreTask?.cancel()
|
|
authenticationMonitor?.cancel()
|
|
}
|
|
|
|
func makeRootController() -> UIViewController {
|
|
let shell = mobileShellFixture(state: .loading)
|
|
navigationControllers = shell.pages.map { page in
|
|
let root: UIViewController = switch page.tab {
|
|
case .passwords:
|
|
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
|
|
case .preferences:
|
|
PreferencesViewController(page: page, authentication: authentication)
|
|
default:
|
|
ShellViewController(page: page)
|
|
}
|
|
let navigation = UINavigationController(rootViewController: root)
|
|
navigation.navigationBar.prefersLargeTitles = true
|
|
navigation.tabBarItem = UITabBarItem(
|
|
title: page.title,
|
|
image: UIImage(systemName: page.systemImage),
|
|
selectedImage: UIImage(systemName: page.selectedSystemImage)
|
|
)
|
|
return navigation
|
|
}
|
|
tabs.viewControllers = navigationControllers
|
|
tabs.delegate = self
|
|
restoreSelectedTab()
|
|
monitorAuthentication()
|
|
return tabs
|
|
}
|
|
|
|
func lockForBackground() {
|
|
guard let authentication else { return }
|
|
try? authentication.manualLock()
|
|
NotificationCenter.default.post(
|
|
name: .ironStorageAuthenticationDidChange,
|
|
object: authentication
|
|
)
|
|
}
|
|
|
|
func tabBarController(
|
|
_ tabBarController: UITabBarController,
|
|
didSelect viewController: UIViewController
|
|
) {
|
|
guard
|
|
let navigation = viewController as? UINavigationController,
|
|
let root = navigation.viewControllers.first as? MobileTabRoot
|
|
else {
|
|
return
|
|
}
|
|
do {
|
|
try setSelectedMobileTab(tab: root.shellTab)
|
|
} catch {
|
|
let alert = UIAlertController(
|
|
title: "Selection Was Not Saved",
|
|
message: error.localizedDescription,
|
|
preferredStyle: .alert
|
|
)
|
|
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
|
tabs.present(alert, animated: true)
|
|
}
|
|
}
|
|
|
|
private func restoreSelectedTab() {
|
|
restoreTask?.cancel()
|
|
restoreTask = Task { [weak self] in
|
|
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
|
|
guard !Task.isCancelled, let self else { return }
|
|
if let index = shell.pages.firstIndex(where: { $0.tab == shell.selectedTab }) {
|
|
tabs.selectedIndex = index
|
|
}
|
|
}
|
|
}
|
|
|
|
private func monitorAuthentication() {
|
|
authenticationMonitor?.cancel()
|
|
guard let authentication else { return }
|
|
authenticationMonitor = Task {
|
|
var wasUnlocked = (try? authentication.state().unlocked) ?? false
|
|
while !Task.isCancelled {
|
|
do {
|
|
try await Task.sleep(for: .seconds(1))
|
|
} catch {
|
|
return
|
|
}
|
|
let isUnlocked = (try? authentication.state().unlocked) ?? false
|
|
if isUnlocked != wasUnlocked {
|
|
wasUnlocked = isUnlocked
|
|
NotificationCenter.default.post(
|
|
name: .ironStorageAuthenticationDidChange,
|
|
object: authentication
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class ShellViewController: UITableViewController, MobileTabRoot {
|
|
fileprivate let shellTab: MobileTab
|
|
private var page: MobilePage
|
|
private var loadTask: Task<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: false)
|
|
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 PreferencesViewController: UITableViewController, MobileTabRoot {
|
|
fileprivate let shellTab = MobileTab.preferences
|
|
private var page: MobilePage
|
|
private let authentication: MobileAuthentication?
|
|
private var state: MobileAuthenticationState?
|
|
private var preferenceTask: Task<Void, Never>?
|
|
private var loadTask: Task<Void, Never>?
|
|
private var loadGeneration = 0
|
|
|
|
init(page: MobilePage, authentication: MobileAuthentication?) {
|
|
self.page = page
|
|
self.authentication = authentication
|
|
super.init(style: .insetGrouped)
|
|
title = page.title
|
|
navigationItem.largeTitleDisplayMode = .always
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
title: "Update Token",
|
|
style: .plain,
|
|
target: self,
|
|
action: #selector(tokenUpdateRequested)
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(authenticationDidChange),
|
|
name: .ironStorageAuthenticationDidChange,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
deinit {
|
|
preferenceTask?.cancel()
|
|
loadTask?.cancel()
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
reloadShell()
|
|
}
|
|
|
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
|
page.state == .ready ? 2 : 0
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
numberOfRowsInSection section: Int
|
|
) -> Int {
|
|
section == 0 ? 1 : 2
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
titleForHeaderInSection section: Int
|
|
) -> String? {
|
|
section == 0 ? "Secure Unlock" : "Authentication Session"
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
titleForFooterInSection section: Int
|
|
) -> String? {
|
|
if section == 0 {
|
|
return "When enabled, the GPG passphrase is device-only, requires a device passcode, and is invalidated when enrolled biometrics change."
|
|
}
|
|
return "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
cellForRowAt indexPath: IndexPath
|
|
) -> UITableViewCell {
|
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
|
var content = cell.defaultContentConfiguration()
|
|
if indexPath.section == 0 {
|
|
content.image = UIImage(systemName: "faceid")
|
|
content.text = "Biometric Unlock"
|
|
content.secondaryText = state?.biometricUnlockEnabled == true
|
|
? "Protected passphrase enrolled"
|
|
: "Manual passphrase required"
|
|
let toggle = UISwitch()
|
|
toggle.isOn = state?.biometricUnlockEnabled == true
|
|
toggle.isEnabled = authentication != nil
|
|
toggle.addTarget(self, action: #selector(biometricToggleChanged(_:)), for: .valueChanged)
|
|
toggle.accessibilityLabel = "Biometric Unlock"
|
|
cell.accessoryView = toggle
|
|
cell.selectionStyle = .none
|
|
} else if indexPath.row == 0 {
|
|
let unlocked = state?.unlocked == true
|
|
content.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
|
|
content.text = unlocked ? "Unlocked" : "Locked"
|
|
content.secondaryText = unlocked
|
|
? "Locks in \(state?.remainingSeconds ?? 0) seconds without activity"
|
|
: "Protected content is masked"
|
|
cell.selectionStyle = .none
|
|
} else {
|
|
content.image = UIImage(systemName: "lock.fill")
|
|
content.text = "Lock Now"
|
|
content.textProperties.color = .systemRed
|
|
content.secondaryText = "Revoke all active authentication handles"
|
|
cell.accessoryType = .disclosureIndicator
|
|
cell.isUserInteractionEnabled = state?.unlocked == true
|
|
cell.contentView.alpha = state?.unlocked == true ? 1 : 0.45
|
|
}
|
|
content.secondaryTextProperties.numberOfLines = 0
|
|
cell.contentConfiguration = content
|
|
return cell
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
guard indexPath.section == 1, indexPath.row == 1, let authentication else { return }
|
|
do {
|
|
try authentication.manualLock()
|
|
refreshState()
|
|
NotificationCenter.default.post(
|
|
name: .ironStorageAuthenticationDidChange,
|
|
object: authentication
|
|
)
|
|
UIAccessibility.post(notification: .announcement, argument: "IronStorage locked")
|
|
} catch let error as MobileAuthenticationFfiError {
|
|
presentAuthenticationFailure(AuthenticationFailure(error))
|
|
} catch {
|
|
presentAuthenticationFailure(.unexpected)
|
|
}
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
apply(page)
|
|
}
|
|
|
|
private func reloadShell() {
|
|
loadGeneration += 1
|
|
let generation = loadGeneration
|
|
loadTask?.cancel()
|
|
loadTask = Task { [weak self] in
|
|
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
|
|
guard
|
|
!Task.isCancelled,
|
|
let self,
|
|
generation == loadGeneration,
|
|
let page = shell.pages.first(where: { $0.tab == .preferences })
|
|
else { return }
|
|
apply(page)
|
|
}
|
|
}
|
|
|
|
private func apply(_ page: MobilePage) {
|
|
self.page = page
|
|
title = page.title
|
|
guard page.state == .ready else {
|
|
var configuration = UIContentUnavailableConfiguration.empty()
|
|
configuration.image = UIImage(systemName: "gearshape")
|
|
configuration.text = page.stateTitle
|
|
configuration.secondaryText = page.stateDetail
|
|
contentUnavailableConfiguration = configuration
|
|
tableView.reloadData()
|
|
return
|
|
}
|
|
contentUnavailableConfiguration = nil
|
|
refreshState()
|
|
}
|
|
|
|
@objc private func tokenUpdateRequested() {
|
|
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
|
|
}
|
|
|
|
@objc private func authenticationDidChange() {
|
|
refreshState()
|
|
}
|
|
|
|
@objc private func biometricToggleChanged(_ sender: UISwitch) {
|
|
guard let authentication else {
|
|
sender.setOn(false, animated: true)
|
|
presentAuthenticationFailure(.unavailable)
|
|
return
|
|
}
|
|
sender.isEnabled = false
|
|
let enabled = sender.isOn
|
|
preferenceTask?.cancel()
|
|
preferenceTask = Task { [weak self, weak sender] in
|
|
let result = await Task.detached(priority: .userInitiated) {
|
|
do {
|
|
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
|
|
try authentication.setBiometricUnlock(enabled: enabled)
|
|
)
|
|
} catch let error as MobileAuthenticationFfiError {
|
|
return .failure(AuthenticationFailure(error))
|
|
} catch {
|
|
return .failure(.unexpected)
|
|
}
|
|
}.value
|
|
guard !Task.isCancelled, let self else { return }
|
|
sender?.isEnabled = true
|
|
switch result {
|
|
case let .success(state):
|
|
self.state = state
|
|
tableView.reloadData()
|
|
NotificationCenter.default.post(
|
|
name: .ironStorageAuthenticationDidChange,
|
|
object: authentication
|
|
)
|
|
case let .failure(failure):
|
|
sender?.setOn(!enabled, animated: true)
|
|
presentAuthenticationFailure(failure)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func refreshState() {
|
|
state = try? authentication?.state()
|
|
tableView.reloadData()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
|
|
fileprivate let shellTab = MobileTab.passwords
|
|
private let path: String?
|
|
private let authentication: MobileAuthentication?
|
|
private var shellPage: MobilePage?
|
|
private var directoryPage: MobilePasswordPage?
|
|
private var loadTask: Task<Void, Never>?
|
|
private var loadGeneration = 0
|
|
|
|
init(
|
|
shellPage: MobilePage,
|
|
authentication: MobileAuthentication?,
|
|
path: String? = nil
|
|
) {
|
|
self.shellPage = shellPage
|
|
self.authentication = authentication
|
|
self.path = path
|
|
super.init(style: .insetGrouped)
|
|
title = shellPage.title
|
|
navigationItem.largeTitleDisplayMode = path == nil ? .always : .never
|
|
refreshControl = UIRefreshControl()
|
|
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(localStoreDidChange),
|
|
name: .ironStorageLocalStoreDidChange,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
private init(path: String, title: String, authentication: MobileAuthentication?) {
|
|
self.path = path
|
|
self.authentication = authentication
|
|
shellPage = nil
|
|
super.init(style: .insetGrouped)
|
|
self.title = title
|
|
navigationItem.largeTitleDisplayMode = .never
|
|
refreshControl = UIRefreshControl()
|
|
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(localStoreDidChange),
|
|
name: .ironStorageLocalStoreDidChange,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
deinit {
|
|
loadTask?.cancel()
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
if let shellPage {
|
|
apply(shellPage)
|
|
} else {
|
|
showLoading()
|
|
}
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
reload()
|
|
}
|
|
|
|
override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
loadGeneration += 1
|
|
loadTask?.cancel()
|
|
refreshControl?.endRefreshing()
|
|
}
|
|
|
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
|
directoryPage?.rows.isEmpty == false ? 1 : 0
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
directoryPage?.rows.count ?? 0
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
cellForRowAt indexPath: IndexPath
|
|
) -> UITableViewCell {
|
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
|
guard let row = directoryPage?.rows[indexPath.row] else { return cell }
|
|
var content = cell.defaultContentConfiguration()
|
|
content.image = UIImage(systemName: row.systemImage)
|
|
content.text = row.title
|
|
content.secondaryText = row.detail
|
|
content.textProperties.numberOfLines = 2
|
|
content.secondaryTextProperties.numberOfLines = 1
|
|
cell.contentConfiguration = content
|
|
cell.accessoryType = .disclosureIndicator
|
|
cell.accessibilityLabel = "\(row.title), \(row.detail)"
|
|
cell.accessibilityHint = row.kind == .directory
|
|
? "Opens this password folder."
|
|
: "Opens the locked password viewer."
|
|
return cell
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
guard let row = directoryPage?.rows[indexPath.row] else { return }
|
|
let controller: UIViewController = switch row.kind {
|
|
case .directory:
|
|
PasswordDirectoryViewController(
|
|
path: row.path,
|
|
title: row.title,
|
|
authentication: authentication
|
|
)
|
|
case .entry:
|
|
LockedPasswordViewController(entry: row, authentication: authentication)
|
|
}
|
|
navigationController?.pushViewController(controller, animated: true)
|
|
}
|
|
|
|
@objc private func refreshRequested() {
|
|
reload()
|
|
}
|
|
|
|
@objc private func localStoreDidChange() {
|
|
guard viewIfLoaded?.window != nil else { return }
|
|
reload()
|
|
}
|
|
|
|
private func reload() {
|
|
loadGeneration += 1
|
|
let generation = loadGeneration
|
|
let selectedID = tableView.indexPathForSelectedRow.flatMap {
|
|
directoryPage?.rows[$0.row].id
|
|
}
|
|
loadTask?.cancel()
|
|
loadTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
if path == nil {
|
|
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
|
|
guard
|
|
!Task.isCancelled,
|
|
generation == loadGeneration,
|
|
let page = shell.pages.first(where: { $0.tab == .passwords })
|
|
else { return }
|
|
apply(page)
|
|
guard page.state == .ready else { return }
|
|
} else {
|
|
showLoading()
|
|
}
|
|
let path = self.path
|
|
let result = await Task.detached(priority: .userInitiated) {
|
|
do {
|
|
return Result<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: UITableViewController {
|
|
private let entry: MobilePasswordRow
|
|
private let authentication: MobileAuthentication?
|
|
private var unlockTask: Task<Void, Never>?
|
|
private var entryTask: Task<Void, Never>?
|
|
private var clipboardTask: Task<Void, Never>?
|
|
private var feedbackTask: Task<Void, Never>?
|
|
private var state: MobileAuthenticationState?
|
|
private var page: MobileEntryPage?
|
|
private var revealedValues: [UInt64: String] = [:]
|
|
|
|
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
|
|
self.entry = entry
|
|
self.authentication = authentication
|
|
super.init(style: .insetGrouped)
|
|
title = entry.title
|
|
navigationItem.largeTitleDisplayMode = .never
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
tableView.register(MobileEntryFieldCell.self, forCellReuseIdentifier: "EntryField")
|
|
tableView.rowHeight = UITableView.automaticDimension
|
|
tableView.estimatedRowHeight = 92
|
|
view.accessibilityIdentifier = entry.id
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(authenticationDidChange),
|
|
name: .ironStorageAuthenticationDidChange,
|
|
object: nil
|
|
)
|
|
refreshState()
|
|
}
|
|
|
|
deinit {
|
|
unlockTask?.cancel()
|
|
entryTask?.cancel()
|
|
clipboardTask?.cancel()
|
|
feedbackTask?.cancel()
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
refreshState()
|
|
}
|
|
|
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
|
page?.sections.count ?? 0
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
numberOfRowsInSection section: Int
|
|
) -> Int {
|
|
page?.sections[section].fields.count ?? 0
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
titleForHeaderInSection section: Int
|
|
) -> String? {
|
|
page?.sections[section].title
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
cellForRowAt indexPath: IndexPath
|
|
) -> UITableViewCell {
|
|
guard
|
|
let cell = tableView.dequeueReusableCell(
|
|
withIdentifier: "EntryField",
|
|
for: indexPath
|
|
) as? MobileEntryFieldCell,
|
|
let field = field(at: indexPath)
|
|
else { return UITableViewCell() }
|
|
cell.configure(
|
|
field: field,
|
|
revealedValue: revealedValues[field.id],
|
|
copy: { [weak self] tappedCell in self?.copy(field, in: tappedCell) },
|
|
reveal: { [weak self] in self?.revealOrHide(field) },
|
|
edit: { [weak self] in self?.edit(field) }
|
|
)
|
|
return cell
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
guard
|
|
let field = field(at: indexPath),
|
|
let cell = tableView.cellForRow(at: indexPath) as? MobileEntryFieldCell
|
|
else { return }
|
|
copy(field, in: cell)
|
|
}
|
|
|
|
@objc private func authenticationDidChange() {
|
|
refreshState()
|
|
}
|
|
|
|
@objc private func unlockRequested() {
|
|
unlock(passphrase: nil)
|
|
}
|
|
|
|
@objc private func lockRequested() {
|
|
guard let authentication else { return }
|
|
do {
|
|
try authentication.manualLock()
|
|
state = try authentication.state()
|
|
NotificationCenter.default.post(
|
|
name: .ironStorageAuthenticationDidChange,
|
|
object: authentication
|
|
)
|
|
} catch let error as MobileAuthenticationFfiError {
|
|
presentAuthenticationFailure(AuthenticationFailure(error))
|
|
} catch {
|
|
presentAuthenticationFailure(.unexpected)
|
|
}
|
|
}
|
|
|
|
private func refreshState() {
|
|
state = try? authentication?.state()
|
|
if state?.unlocked == true {
|
|
if page == nil { loadEntry() }
|
|
} else {
|
|
maskAndDiscardEntry()
|
|
showLocked()
|
|
}
|
|
}
|
|
|
|
private func unlock(passphrase: String?) {
|
|
guard let authentication else {
|
|
presentAuthenticationFailure(.unavailable)
|
|
return
|
|
}
|
|
unlockTask?.cancel()
|
|
showLoading(title: "Unlocking Password")
|
|
let path = entry.path
|
|
unlockTask = Task { [weak self] in
|
|
let result = await Task.detached(priority: .userInitiated) {
|
|
do {
|
|
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
|
|
try authentication.unlockEntry(path: path, passphrase: passphrase)
|
|
)
|
|
} catch let error as MobileAuthenticationFfiError {
|
|
return .failure(AuthenticationFailure(error))
|
|
} catch {
|
|
return .failure(.unexpected)
|
|
}
|
|
}.value
|
|
guard !Task.isCancelled, let self else { return }
|
|
switch result {
|
|
case let .success(state):
|
|
self.state = state
|
|
NotificationCenter.default.post(
|
|
name: .ironStorageAuthenticationDidChange,
|
|
object: authentication
|
|
)
|
|
loadEntry()
|
|
UIAccessibility.post(notification: .announcement, argument: "Password unlocked")
|
|
case let .failure(failure):
|
|
if passphrase == nil,
|
|
failure.kind == .passphraseRequired || failure.kind == .biometryUnavailable {
|
|
promptForPassphrase(message: failure.detail)
|
|
} else if failure.kind != .cancelled {
|
|
presentAuthenticationFailure(failure)
|
|
showLocked()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func promptForPassphrase(message: String) {
|
|
let alert = UIAlertController(
|
|
title: "GPG Key Passphrase",
|
|
message: message,
|
|
preferredStyle: .alert
|
|
)
|
|
alert.addTextField { field in
|
|
field.isSecureTextEntry = true
|
|
field.textContentType = .password
|
|
field.placeholder = "Passphrase"
|
|
field.returnKeyType = .go
|
|
}
|
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
|
|
guard let field = alert?.textFields?.first, let value = field.text, !value.isEmpty else {
|
|
return
|
|
}
|
|
field.text = nil
|
|
self?.unlock(passphrase: value)
|
|
})
|
|
present(alert, animated: true)
|
|
}
|
|
|
|
private func loadEntry() {
|
|
guard let authentication else {
|
|
showLocked()
|
|
return
|
|
}
|
|
entryTask?.cancel()
|
|
showLoading(title: "Opening Password")
|
|
let path = entry.path
|
|
entryTask = Task { [weak self] in
|
|
let result = await Task.detached(priority: .userInitiated) {
|
|
do {
|
|
return Result<MobileEntryPage, AuthenticationFailure>.success(
|
|
try authentication.entryPage(path: path)
|
|
)
|
|
} catch let error as MobileAuthenticationFfiError {
|
|
return .failure(AuthenticationFailure(error))
|
|
} catch {
|
|
return .failure(.unexpected)
|
|
}
|
|
}.value
|
|
guard !Task.isCancelled, let self else { return }
|
|
switch result {
|
|
case let .success(page):
|
|
self.page = page
|
|
title = page.title
|
|
contentUnavailableConfiguration = nil
|
|
installLockButton()
|
|
tableView.reloadData()
|
|
case let .failure(failure):
|
|
handle(failure)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func field(at indexPath: IndexPath) -> MobileEntryField? {
|
|
guard
|
|
let page,
|
|
page.sections.indices.contains(indexPath.section),
|
|
page.sections[indexPath.section].fields.indices.contains(indexPath.row)
|
|
else { return nil }
|
|
return page.sections[indexPath.section].fields[indexPath.row]
|
|
}
|
|
|
|
private func revealOrHide(_ field: MobileEntryField) {
|
|
if revealedValues.removeValue(forKey: field.id) != nil {
|
|
tableView.reloadData()
|
|
UIAccessibility.post(notification: .announcement, argument: "\(field.label) hidden")
|
|
return
|
|
}
|
|
performFieldAction(field) { authentication, path in
|
|
try authentication.revealEntryField(path: path, field: field.id)
|
|
} success: { [weak self] value in
|
|
self?.revealedValues[field.id] = value
|
|
self?.tableView.reloadData()
|
|
UIAccessibility.post(notification: .announcement, argument: "\(field.label) revealed")
|
|
}
|
|
}
|
|
|
|
private func copy(_ field: MobileEntryField, in cell: MobileEntryFieldCell?) {
|
|
cell?.flashCopied()
|
|
performFieldAction(field) { authentication, path in
|
|
try authentication.copyEntryField(path: path, field: field.id)
|
|
} success: { [weak self] copy in
|
|
guard let self else { return }
|
|
UIPasteboard.general.string = copy.value
|
|
showFeedback("\(field.label) copied")
|
|
UIAccessibility.post(notification: .announcement, argument: "\(field.label) copied")
|
|
clipboardTask?.cancel()
|
|
clipboardTask = Task { @MainActor in
|
|
do {
|
|
try await Task.sleep(for: .seconds(copy.timeoutSeconds))
|
|
} catch {
|
|
return
|
|
}
|
|
if UIPasteboard.general.string == copy.value {
|
|
UIPasteboard.general.items = []
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func edit(_ field: MobileEntryField) {
|
|
performFieldAction(field) { authentication, path in
|
|
try authentication.revealEntryField(path: path, field: field.id)
|
|
} success: { [weak self] value in
|
|
guard let self else { return }
|
|
let editor = MobileEntryEditorViewController(field: field, value: value) {
|
|
[weak self] updated in self?.save(updated, for: field)
|
|
}
|
|
present(UINavigationController(rootViewController: editor), animated: true)
|
|
}
|
|
}
|
|
|
|
private func save(_ value: String, for field: MobileEntryField) {
|
|
guard let authentication else { return }
|
|
entryTask?.cancel()
|
|
showLoading(title: "Saving Password")
|
|
let path = entry.path
|
|
entryTask = Task { [weak self] in
|
|
let result = await Task.detached(priority: .userInitiated) {
|
|
do {
|
|
try authentication.touchUserActivity()
|
|
return Result<MobileEntryPage, AuthenticationFailure>.success(
|
|
try authentication.replaceEntryField(
|
|
path: path,
|
|
field: field.id,
|
|
value: value
|
|
)
|
|
)
|
|
} catch let error as MobileAuthenticationFfiError {
|
|
return .failure(AuthenticationFailure(error))
|
|
} catch {
|
|
return .failure(.unexpected)
|
|
}
|
|
}.value
|
|
guard !Task.isCancelled, let self else { return }
|
|
switch result {
|
|
case let .success(page):
|
|
self.page = page
|
|
revealedValues.removeValue(forKey: field.id)
|
|
contentUnavailableConfiguration = nil
|
|
tableView.reloadData()
|
|
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
|
|
showFeedback("\(field.label) saved")
|
|
case let .failure(failure):
|
|
handle(failure)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func performFieldAction<Value: Sendable>(
|
|
_ field: MobileEntryField,
|
|
operation: @escaping @Sendable (MobileAuthentication, String) throws -> Value,
|
|
success: @escaping @MainActor (Value) -> Void
|
|
) {
|
|
guard let authentication else {
|
|
presentAuthenticationFailure(.unavailable)
|
|
return
|
|
}
|
|
let path = entry.path
|
|
Task { [weak self] in
|
|
let result = await Task.detached(priority: .userInitiated) {
|
|
do {
|
|
try authentication.touchUserActivity()
|
|
return Result<Value, AuthenticationFailure>.success(
|
|
try operation(authentication, path)
|
|
)
|
|
} catch let error as MobileAuthenticationFfiError {
|
|
return .failure(AuthenticationFailure(error))
|
|
} catch {
|
|
return .failure(.unexpected)
|
|
}
|
|
}.value
|
|
guard !Task.isCancelled, let self else { return }
|
|
switch result {
|
|
case let .success(value): success(value)
|
|
case let .failure(failure): handle(failure)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handle(_ failure: AuthenticationFailure) {
|
|
if failure.kind == .expired {
|
|
state = try? authentication?.state()
|
|
maskAndDiscardEntry()
|
|
showLocked()
|
|
NotificationCenter.default.post(
|
|
name: .ironStorageAuthenticationDidChange,
|
|
object: authentication
|
|
)
|
|
}
|
|
presentAuthenticationFailure(failure)
|
|
}
|
|
|
|
private func maskAndDiscardEntry() {
|
|
entryTask?.cancel()
|
|
feedbackTask?.cancel()
|
|
page = nil
|
|
revealedValues.removeAll(keepingCapacity: false)
|
|
tableView.reloadData()
|
|
navigationItem.rightBarButtonItem = nil
|
|
navigationItem.prompt = nil
|
|
title = entry.title
|
|
}
|
|
|
|
private func showLocked() {
|
|
var configuration = UIContentUnavailableConfiguration.empty()
|
|
configuration.image = UIImage(systemName: "lock.fill")
|
|
configuration.text = "Locked Password"
|
|
configuration.secondaryText =
|
|
"Authenticate to decrypt this entry. Browsing remains available while locked."
|
|
configuration.button = .filled()
|
|
configuration.button.title = "Unlock"
|
|
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
|
|
self?.unlockRequested()
|
|
}
|
|
contentUnavailableConfiguration = configuration
|
|
}
|
|
|
|
private func showLoading(title: String) {
|
|
var configuration = UIContentUnavailableConfiguration.loading()
|
|
configuration.text = title
|
|
configuration.secondaryText = "Reading the structured entry from secure storage."
|
|
contentUnavailableConfiguration = configuration
|
|
}
|
|
|
|
private func showFeedback(_ message: String) {
|
|
feedbackTask?.cancel()
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
image: UIImage(systemName: "checkmark.circle.fill"),
|
|
style: .plain,
|
|
target: nil,
|
|
action: nil
|
|
)
|
|
navigationItem.rightBarButtonItem?.accessibilityLabel = message
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
feedbackTask = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(for: .seconds(2))
|
|
} catch {
|
|
return
|
|
}
|
|
guard let self else { return }
|
|
if page != nil { installLockButton() }
|
|
}
|
|
}
|
|
|
|
private func installLockButton() {
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
image: UIImage(systemName: "lock.fill"),
|
|
style: .plain,
|
|
target: self,
|
|
action: #selector(lockRequested)
|
|
)
|
|
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class MobileEntryFieldCell: UITableViewCell {
|
|
private let iconView = UIImageView()
|
|
private let labelView = UILabel()
|
|
private let valueView = UITextView()
|
|
private let detailView = UILabel()
|
|
private let diagnosticView = UILabel()
|
|
private let revealButton = UIButton(type: .system)
|
|
private let editButton = UIButton(type: .system)
|
|
private var copyValue: ((MobileEntryFieldCell) -> Void)?
|
|
private var highlightTask: Task<Void, Never>?
|
|
|
|
private static let revealAction = UIAction.Identifier("reveal-entry-field")
|
|
private static let editAction = UIAction.Identifier("edit-entry-field")
|
|
|
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
selectionStyle = .default
|
|
iconView.tintColor = .secondaryLabel
|
|
iconView.setContentHuggingPriority(.required, for: .horizontal)
|
|
iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
labelView.font = .preferredFont(forTextStyle: .caption1)
|
|
labelView.textColor = .secondaryLabel
|
|
labelView.adjustsFontForContentSizeCategory = true
|
|
valueView.font = .preferredFont(forTextStyle: .body)
|
|
valueView.adjustsFontForContentSizeCategory = true
|
|
valueView.backgroundColor = .clear
|
|
valueView.isEditable = false
|
|
valueView.isScrollEnabled = false
|
|
valueView.textContainerInset = .zero
|
|
valueView.textContainer.lineFragmentPadding = 0
|
|
let valueTap = UITapGestureRecognizer(target: self, action: #selector(valueTapped))
|
|
valueTap.cancelsTouchesInView = false
|
|
valueView.addGestureRecognizer(valueTap)
|
|
detailView.font = .preferredFont(forTextStyle: .footnote)
|
|
detailView.textColor = .secondaryLabel
|
|
detailView.adjustsFontForContentSizeCategory = true
|
|
detailView.numberOfLines = 0
|
|
diagnosticView.font = .preferredFont(forTextStyle: .footnote)
|
|
diagnosticView.textColor = .systemOrange
|
|
diagnosticView.adjustsFontForContentSizeCategory = true
|
|
diagnosticView.numberOfLines = 0
|
|
revealButton.configuration = .plain()
|
|
editButton.configuration = .plain()
|
|
editButton.configuration?.image = UIImage(systemName: "pencil")
|
|
editButton.accessibilityLabel = "Edit field"
|
|
let labels = UIStackView(arrangedSubviews: [labelView, valueView, detailView, diagnosticView])
|
|
labels.axis = .vertical
|
|
labels.spacing = 3
|
|
labels.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
labels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
let actions = UIStackView(arrangedSubviews: [revealButton, editButton])
|
|
actions.distribution = .fillEqually
|
|
NSLayoutConstraint.activate([
|
|
actions.widthAnchor.constraint(equalToConstant: 88),
|
|
actions.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
|
])
|
|
actions.setContentHuggingPriority(.required, for: .horizontal)
|
|
actions.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
let row = UIStackView(arrangedSubviews: [iconView, labels, actions])
|
|
row.alignment = .top
|
|
row.spacing = 12
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
contentView.addSubview(row)
|
|
NSLayoutConstraint.activate([
|
|
iconView.widthAnchor.constraint(equalToConstant: 24),
|
|
iconView.heightAnchor.constraint(equalToConstant: 24),
|
|
row.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
|
row.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
|
row.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
|
|
row.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
|
|
])
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
func configure(
|
|
field: MobileEntryField,
|
|
revealedValue: String?,
|
|
copy: @escaping (MobileEntryFieldCell) -> Void,
|
|
reveal: @escaping () -> Void,
|
|
edit: @escaping () -> Void
|
|
) {
|
|
let value = revealedValue ?? field.value ?? field.maskedValue
|
|
iconView.image = UIImage(systemName: field.systemImage)
|
|
labelView.text = field.label
|
|
valueView.text = value
|
|
copyValue = copy
|
|
valueView.textColor = field.sensitive && revealedValue == nil ? .secondaryLabel : .label
|
|
valueView.isSelectable = field.selectable
|
|
detailView.text = field.detail
|
|
detailView.isHidden = field.detail == nil
|
|
diagnosticView.text = field.diagnostic
|
|
diagnosticView.isHidden = field.diagnostic == nil
|
|
revealButton.configuration?.image = UIImage(
|
|
systemName: revealedValue == nil ? "eye" : "eye.slash"
|
|
)
|
|
revealButton.accessibilityLabel = revealedValue == nil ? "Reveal field" : "Hide field"
|
|
revealButton.removeAction(identifiedBy: Self.revealAction, for: .touchUpInside)
|
|
revealButton.addAction(
|
|
UIAction(identifier: Self.revealAction) { _ in reveal() },
|
|
for: .touchUpInside
|
|
)
|
|
revealButton.alpha = field.sensitive ? 1 : 0
|
|
revealButton.isEnabled = field.sensitive
|
|
revealButton.accessibilityElementsHidden = !field.sensitive
|
|
editButton.removeAction(identifiedBy: Self.editAction, for: .touchUpInside)
|
|
editButton.addAction(
|
|
UIAction(identifier: Self.editAction) { _ in edit() },
|
|
for: .touchUpInside
|
|
)
|
|
editButton.alpha = field.editable ? 1 : 0
|
|
editButton.isEnabled = field.editable
|
|
editButton.accessibilityElementsHidden = !field.editable
|
|
accessibilityLabel = [field.label, value, field.detail, field.diagnostic]
|
|
.compactMap { $0 }
|
|
.joined(separator: ", ")
|
|
accessibilityHint = field.sensitive
|
|
? "Double tap the row to copy. Reveal and Edit buttons follow."
|
|
: "Double tap the row to copy. An Edit button follows."
|
|
}
|
|
|
|
func flashCopied() {
|
|
highlightTask?.cancel()
|
|
setHighlighted(true, animated: false)
|
|
highlightTask = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(for: .milliseconds(350))
|
|
} catch {
|
|
return
|
|
}
|
|
self?.setHighlighted(false, animated: true)
|
|
}
|
|
}
|
|
|
|
override func prepareForReuse() {
|
|
super.prepareForReuse()
|
|
highlightTask?.cancel()
|
|
setHighlighted(false, animated: false)
|
|
}
|
|
|
|
@objc private func valueTapped() {
|
|
copyValue?(self)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class MobileEntryEditorViewController: UIViewController {
|
|
private let field: MobileEntryField
|
|
private let valueView = UITextView()
|
|
private let save: (String) -> Void
|
|
|
|
init(field: MobileEntryField, value: String, save: @escaping (String) -> Void) {
|
|
self.field = field
|
|
self.save = save
|
|
super.init(nibName: nil, bundle: nil)
|
|
valueView.text = value
|
|
title = "Edit \(field.label)"
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .systemGroupedBackground
|
|
valueView.font = .preferredFont(forTextStyle: .body)
|
|
valueView.adjustsFontForContentSizeCategory = true
|
|
valueView.autocorrectionType = field.sensitive ? .no : .default
|
|
valueView.autocapitalizationType = field.sensitive ? .none : .sentences
|
|
valueView.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(valueView)
|
|
NSLayoutConstraint.activate([
|
|
valueView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
|
|
valueView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
|
|
valueView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
|
valueView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -16),
|
|
])
|
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
|
systemItem: .cancel,
|
|
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
|
|
)
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
systemItem: .save,
|
|
primaryAction: UIAction { [weak self] _ in
|
|
guard let self else { return }
|
|
let value = valueView.text ?? ""
|
|
dismiss(animated: true) { self.save(value) }
|
|
}
|
|
)
|
|
valueView.becomeFirstResponder()
|
|
}
|
|
}
|
|
|
|
private struct AuthenticationFailure: Error, Sendable {
|
|
let kind: MobileAuthenticationErrorKind
|
|
let title: String
|
|
let detail: String
|
|
|
|
init(_ error: MobileAuthenticationFfiError) {
|
|
switch error {
|
|
case let .Failed(kind, title, detail):
|
|
self.kind = kind
|
|
self.title = title
|
|
self.detail = detail
|
|
}
|
|
}
|
|
|
|
static let unexpected = AuthenticationFailure(
|
|
kind: .secureStorage,
|
|
title: "Unlock Failed",
|
|
detail: "IronStorage could not complete authentication."
|
|
)
|
|
|
|
static let unavailable = AuthenticationFailure(
|
|
kind: .configuration,
|
|
title: "Authentication Is Unavailable",
|
|
detail: "Finish password-store setup before unlocking entries."
|
|
)
|
|
|
|
private init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
|
|
self.kind = kind
|
|
self.title = title
|
|
self.detail = detail
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private extension UIViewController {
|
|
func presentAuthenticationFailure(_ failure: AuthenticationFailure) {
|
|
let alert = UIAlertController(
|
|
title: failure.title,
|
|
message: failure.detail,
|
|
preferredStyle: .alert
|
|
)
|
|
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
|
present(alert, animated: true)
|
|
}
|
|
}
|
|
|
|
private struct HomeFailure: Error, Sendable {
|
|
let kind: MobileHomeErrorKind
|
|
let title: String
|
|
let detail: String
|
|
|
|
init(_ error: MobileHomeFfiError) {
|
|
switch error {
|
|
case let .Failed(kind, title, detail):
|
|
self.kind = kind
|
|
self.title = title
|
|
self.detail = detail
|
|
}
|
|
}
|
|
|
|
static let unexpected = HomeFailure(
|
|
kind: .repository,
|
|
title: "Remote Activity Failed",
|
|
detail: "IronStorage could not load the storage-provided Home page."
|
|
)
|
|
|
|
private init(kind: MobileHomeErrorKind, title: String, detail: String) {
|
|
self.kind = kind
|
|
self.title = title
|
|
self.detail = detail
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class MobileCommitActivityViewController: UITableViewController {
|
|
private let commit: MobileHomeCommit
|
|
|
|
init(commit: MobileHomeCommit, incoming: Bool) {
|
|
self.commit = commit
|
|
super.init(style: .insetGrouped)
|
|
title = incoming ? "Incoming Commit" : "Outgoing Commit"
|
|
navigationItem.largeTitleDisplayMode = .never
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
|
|
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
section == 0 ? 1 : max(commit.changes.count, 1)
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
titleForHeaderInSection section: Int
|
|
) -> String? {
|
|
section == 0 ? "Commit" : "Password Store Changes"
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
cellForRowAt indexPath: IndexPath
|
|
) -> UITableViewCell {
|
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
|
var content = cell.defaultContentConfiguration()
|
|
if indexPath.section == 0 {
|
|
content.image = UIImage(systemName: commit.systemImage)
|
|
content.text = commit.title
|
|
content.secondaryText = "\(commit.detail)\n\(formattedDate)"
|
|
} else if commit.changes.isEmpty {
|
|
content.image = UIImage(systemName: "minus.circle")
|
|
content.text = "No File Changes"
|
|
content.secondaryText = "This commit does not change password-store files."
|
|
} else {
|
|
let change = commit.changes[indexPath.row]
|
|
content.image = UIImage(systemName: change.systemImage)
|
|
content.text = change.title
|
|
content.secondaryText = change.detail
|
|
}
|
|
content.secondaryTextProperties.numberOfLines = 0
|
|
cell.contentConfiguration = content
|
|
cell.selectionStyle = .none
|
|
return cell
|
|
}
|
|
|
|
private var formattedDate: String {
|
|
Date(timeIntervalSince1970: TimeInterval(commit.timestamp)).formatted(
|
|
date: .abbreviated,
|
|
time: .shortened
|
|
)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class TokenUpdateViewController: UITableViewController {
|
|
private let accountField = UITextField()
|
|
private let tokenField = UITextField()
|
|
private var updateTask: Task<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
|
|
}
|
|
}
|