Implement iPhone and Apple Watch preferences (#53)
This commit is contained in:
@@ -2,6 +2,7 @@ import UIKit
|
||||
import AVFoundation
|
||||
import Vision
|
||||
import VisionKit
|
||||
import WatchConnectivity
|
||||
|
||||
extension Notification.Name {
|
||||
static let ironStorageLocalStoreDidChange = Notification.Name(
|
||||
@@ -107,6 +108,13 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
}
|
||||
tabs.viewControllers = navigationControllers
|
||||
tabs.delegate = self
|
||||
if let appearance = try? authentication?.mobileAppearance() {
|
||||
tabs.overrideUserInterfaceStyle = switch appearance {
|
||||
case .system: .unspecified
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
}
|
||||
restoreSelectedTab()
|
||||
monitorAuthentication()
|
||||
return tabs
|
||||
@@ -844,13 +852,17 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PreferencesViewController: UITableViewController, MobileTabRoot {
|
||||
private final class PreferencesViewController: UITableViewController, MobileTabRoot,
|
||||
WCSessionDelegate
|
||||
{
|
||||
fileprivate let shellTab = MobileTab.preferences
|
||||
private var page: MobilePage
|
||||
private let authentication: MobileAuthentication?
|
||||
private let keyTransfer = try? mobileKeyTransfer()
|
||||
private var state: MobileAuthenticationState?
|
||||
private var preferences: MobilePreferences?
|
||||
private var gitIdentity: MobileGitIdentity?
|
||||
private var watchSession: WCSession?
|
||||
private var preferenceTask: Task<Void, Never>?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var loadGeneration = 0
|
||||
@@ -859,6 +871,12 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
self.page = page
|
||||
self.authentication = authentication
|
||||
super.init(style: .insetGrouped)
|
||||
if WCSession.isSupported() {
|
||||
let session = WCSession.default
|
||||
watchSession = session
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
}
|
||||
title = page.title
|
||||
navigationItem.largeTitleDisplayMode = .always
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@@ -881,6 +899,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
}
|
||||
|
||||
deinit {
|
||||
watchSession?.delegate = nil
|
||||
preferenceTask?.cancel()
|
||||
loadTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
@@ -892,14 +911,18 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
page.state == .ready ? 4 : 0
|
||||
page.state == .ready ? 7 : 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
numberOfRowsInSection section: Int
|
||||
) -> Int {
|
||||
section == 1 ? 2 : (section == 3 ? 2 : 1)
|
||||
switch section {
|
||||
case 0: 2
|
||||
case 1, 2, 3: 3
|
||||
default: 1
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
@@ -907,10 +930,13 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
switch section {
|
||||
case 0: "Git Commit Identity"
|
||||
case 1: "GPG Key Transfer"
|
||||
case 2: "Secure Unlock"
|
||||
default: "Authentication Session"
|
||||
case 0: "Password Store"
|
||||
case 1: "Application Token"
|
||||
case 2: "GPG Key & Recovery"
|
||||
case 3: "Authentication"
|
||||
case 4: "Appearance"
|
||||
case 5: "Apple Watch"
|
||||
default: "Git Commit Identity"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,16 +944,22 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
if section == 0 {
|
||||
return "Used as the author for commits created by the iPhone app."
|
||||
switch section {
|
||||
case 0:
|
||||
"Non-secret repository and server identities from the shared configuration."
|
||||
case 1:
|
||||
"The application token is stored only in protected system storage and is never displayed."
|
||||
case 2:
|
||||
"Import provides initial or recovery key setup. Private-key transfers require explicit confirmation and passphrase validation."
|
||||
case 3:
|
||||
"Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
|
||||
case 5:
|
||||
"Pairing is read from the device; TOTP selection and synchronization state come from Rust."
|
||||
case 6:
|
||||
"Used as the author for commits created by the iPhone app."
|
||||
default:
|
||||
nil
|
||||
}
|
||||
if section == 1 {
|
||||
return "Scan or display ASCII-armored GPG keys. Private-key transfers require explicit confirmation and passphrase validation."
|
||||
}
|
||||
if section == 2 {
|
||||
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(
|
||||
@@ -936,24 +968,58 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
if indexPath.section == 0 {
|
||||
content.image = UIImage(systemName: "person.crop.circle")
|
||||
content.text = gitIdentity?.name ?? "Git Commit Identity"
|
||||
content.secondaryText = gitIdentity?.email ?? "Set the commit author name and email"
|
||||
switch (indexPath.section, indexPath.row) {
|
||||
case (0, 0):
|
||||
content.image = UIImage(systemName: "externaldrive.fill")
|
||||
content.text = preferences?.repositoryTitle ?? "Password Store"
|
||||
content.secondaryText = preferences?.repositoryUrl ?? "Repository unavailable"
|
||||
cell.selectionStyle = .none
|
||||
case (0, 1):
|
||||
content.image = UIImage(systemName: "server.rack")
|
||||
content.text = preferences?.serverTitle ?? "HTTPS Server"
|
||||
content.secondaryText = preferences?.serverIdentity ?? "Server identity unavailable"
|
||||
cell.selectionStyle = .none
|
||||
case (1, 0):
|
||||
content.image = UIImage(systemName: "key.fill")
|
||||
content.text = preferences?.applicationAccount ?? "No Application Token"
|
||||
content.secondaryText = preferences?.applicationAccount == nil
|
||||
? "Git network operations require a replacement token"
|
||||
: "Token stored in protected system storage"
|
||||
cell.selectionStyle = .none
|
||||
case (1, 1):
|
||||
content.image = UIImage(systemName: "arrow.triangle.2.circlepath")
|
||||
content.text = "Replace Token"
|
||||
content.secondaryText = "Store a new HTTPS application token"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.isUserInteractionEnabled = authentication != nil
|
||||
cell.contentView.alpha = authentication == nil ? 0.45 : 1
|
||||
} else if indexPath.section == 1 {
|
||||
let importing = indexPath.row == 0
|
||||
case (1, 2):
|
||||
content.image = UIImage(systemName: "trash")
|
||||
content.text = "Remove Token"
|
||||
content.textProperties.color = .systemRed
|
||||
content.secondaryText = "Disable authenticated Git network operations"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.isUserInteractionEnabled = preferences?.applicationAccount != nil
|
||||
cell.contentView.alpha = preferences?.applicationAccount == nil ? 0.45 : 1
|
||||
case (2, 0):
|
||||
content.image = UIImage(systemName: "person.badge.key.fill")
|
||||
content.text = preferences?.defaultKeyTitle ?? "Default GPG Key"
|
||||
content.secondaryText = preferences?.defaultKeyFingerprint ?? "Key unavailable"
|
||||
cell.selectionStyle = .none
|
||||
case (2, 1), (2, 2):
|
||||
let importing = indexPath.row == 1
|
||||
content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode")
|
||||
content.text = importing ? "Import GPG Key" : "Export GPG Key"
|
||||
content.secondaryText = importing
|
||||
? "Scan one or more transfer QR codes"
|
||||
? "Scan an initial or recovery key transfer"
|
||||
: "Display public or private key armor"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.isUserInteractionEnabled = keyTransfer != nil
|
||||
cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1
|
||||
} else if indexPath.section == 2 {
|
||||
case (3, 0):
|
||||
content.image = UIImage(systemName: "timer")
|
||||
content.text = "Inactivity Timeout"
|
||||
content.secondaryText = "\(preferences?.authenticationTimeoutSeconds ?? 0) seconds"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
case (3, 1):
|
||||
content.image = UIImage(systemName: "faceid")
|
||||
content.text = "Biometric Unlock"
|
||||
content.secondaryText = state?.biometricUnlockEnabled == true
|
||||
@@ -966,37 +1032,51 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
toggle.accessibilityLabel = "Biometric Unlock"
|
||||
cell.accessoryView = toggle
|
||||
cell.selectionStyle = .none
|
||||
} else if indexPath.row == 0 {
|
||||
case (3, 2):
|
||||
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"
|
||||
content.secondaryText = unlocked
|
||||
? "Revoke all active authentication handles"
|
||||
: "IronStorage is already locked"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.isUserInteractionEnabled = unlocked
|
||||
cell.contentView.alpha = unlocked ? 1 : 0.45
|
||||
case (4, 0):
|
||||
content.image = UIImage(systemName: "circle.lefthalf.filled")
|
||||
content.text = "App Appearance"
|
||||
content.secondaryText = appearanceTitle(preferences?.appearance ?? .system)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
case (5, 0):
|
||||
content.image = UIImage(systemName: watchImage(preferences?.watchState))
|
||||
content.text = preferences?.watchTitle ?? "Checking Apple Watch"
|
||||
content.secondaryText = preferences?.watchDetail ?? "Reading pairing state"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
default:
|
||||
content.image = UIImage(systemName: "person.crop.circle")
|
||||
content.text = gitIdentity?.name ?? "Git Commit Identity"
|
||||
content.secondaryText = gitIdentity?.email ?? "Set the commit author name and email"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.isUserInteractionEnabled = state?.unlocked == true
|
||||
cell.contentView.alpha = state?.unlocked == true ? 1 : 0.45
|
||||
}
|
||||
content.secondaryTextProperties.numberOfLines = 0
|
||||
cell.contentConfiguration = content
|
||||
cell.accessibilityLabel = [content.text, content.secondaryText]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: ", ")
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 0 {
|
||||
editGitIdentity()
|
||||
return
|
||||
}
|
||||
if indexPath.section == 1 {
|
||||
switch (indexPath.section, indexPath.row) {
|
||||
case (1, 1):
|
||||
tokenUpdateRequested()
|
||||
case (1, 2):
|
||||
removeTokenRequested()
|
||||
case (2, 1), (2, 2):
|
||||
guard let keyTransfer else { return }
|
||||
if indexPath.row == 0 {
|
||||
if indexPath.row == 1 {
|
||||
requestKeyScanner(keyTransfer)
|
||||
} else {
|
||||
navigationController?.pushViewController(
|
||||
@@ -1004,9 +1084,23 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
return
|
||||
case (3, 0):
|
||||
editAuthenticationTimeout()
|
||||
case (3, 2):
|
||||
lockNow()
|
||||
case (4, 0):
|
||||
chooseAppearance()
|
||||
case (5, 0):
|
||||
openWatchPreference()
|
||||
case (6, 0):
|
||||
editGitIdentity()
|
||||
default:
|
||||
break
|
||||
}
|
||||
guard indexPath.section == 3, indexPath.row == 1, let authentication else { return }
|
||||
}
|
||||
|
||||
private func lockNow() {
|
||||
guard let authentication else { return }
|
||||
do {
|
||||
try authentication.manualLock()
|
||||
refreshState()
|
||||
@@ -1188,7 +1282,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
switch result {
|
||||
case let .success(identity):
|
||||
gitIdentity = identity
|
||||
tableView.reloadSections(IndexSet(integer: 0), with: .automatic)
|
||||
tableView.reloadSections(IndexSet(integer: 6), with: .automatic)
|
||||
UIAccessibility.post(
|
||||
notification: .announcement,
|
||||
argument: "Git commit identity updated"
|
||||
@@ -1199,6 +1293,147 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTokenRequested() {
|
||||
guard preferences?.applicationAccount != nil else { return }
|
||||
let alert = UIAlertController(
|
||||
title: "Remove Application Token?",
|
||||
message: "Local password browsing remains available, but Fetch, Pull, and Push will fail until a replacement token is stored.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Remove Token", style: .destructive) {
|
||||
[weak self] _ in
|
||||
self?.runPreferenceUpdate(announcement: "Application token removed") {
|
||||
try self?.authentication?.removeApplicationToken()
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func editAuthenticationTimeout() {
|
||||
let alert = UIAlertController(
|
||||
title: "Inactivity Timeout",
|
||||
message: "Enter a shared timeout from 1 to 86400 seconds.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addTextField { [preferences] field in
|
||||
field.keyboardType = .numberPad
|
||||
field.text = String(preferences?.authenticationTimeoutSeconds ?? 120)
|
||||
field.accessibilityLabel = "Timeout in seconds"
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Save", style: .default) {
|
||||
[weak self, weak alert] _ in
|
||||
let seconds = UInt64(alert?.textFields?.first?.text ?? "") ?? 0
|
||||
self?.runPreferenceUpdate(announcement: "Inactivity timeout updated") {
|
||||
try self?.authentication?.setAuthenticationTimeout(seconds: seconds)
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func chooseAppearance() {
|
||||
let alert = UIAlertController(title: "App Appearance", message: nil, preferredStyle: .actionSheet)
|
||||
for appearance in [MobileAppearance.system, .light, .dark] {
|
||||
let selected = appearance == preferences?.appearance
|
||||
alert.addAction(UIAlertAction(
|
||||
title: "\(selected ? "✓ " : "")\(appearanceTitle(appearance))",
|
||||
style: .default
|
||||
) { [weak self] _ in
|
||||
self?.runPreferenceUpdate(announcement: "Appearance updated") {
|
||||
try self?.authentication?.setMobileAppearance(appearance: appearance)
|
||||
}
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.popoverPresentationController?.sourceView = tableView
|
||||
alert.popoverPresentationController?.sourceRect = tableView.bounds
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func runPreferenceUpdate(
|
||||
announcement: String,
|
||||
operation: @escaping @Sendable () throws -> Void
|
||||
) {
|
||||
preferenceTask?.cancel()
|
||||
preferenceTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try operation()
|
||||
return Result<Void, AuthenticationFailure>.success(())
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
switch result {
|
||||
case .success:
|
||||
refreshState()
|
||||
UIAccessibility.post(notification: .announcement, argument: announcement)
|
||||
case let .failure(failure):
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openWatchPreference() {
|
||||
switch preferences?.watchState {
|
||||
case .ready, .pending:
|
||||
selectTotpTab()
|
||||
case .notPaired:
|
||||
presentWatchGuidance(
|
||||
title: "Pair an Apple Watch",
|
||||
message: "Open the Watch app on this iPhone and pair an Apple Watch before configuring IronStorage synchronization."
|
||||
)
|
||||
case .appNotInstalled:
|
||||
presentWatchGuidance(
|
||||
title: "Install IronStorage on Apple Watch",
|
||||
message: "Open the Watch app on this iPhone and install the IronStorage companion on the paired watch."
|
||||
)
|
||||
default:
|
||||
presentWatchGuidance(
|
||||
title: "Apple Watch Unavailable",
|
||||
message: "Apple Watch connectivity is unavailable on this device."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentWatchGuidance(title: String, message: String) {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func selectTotpTab() {
|
||||
guard let controllers = tabBarController?.viewControllers else { return }
|
||||
tabBarController?.selectedIndex = controllers.firstIndex { controller in
|
||||
guard
|
||||
let navigation = controller as? UINavigationController,
|
||||
let root = navigation.viewControllers.first as? MobileTabRoot
|
||||
else { return false }
|
||||
return root.shellTab == .totp
|
||||
} ?? tabBarController?.selectedIndex ?? 0
|
||||
}
|
||||
|
||||
private func appearanceTitle(_ appearance: MobileAppearance) -> String {
|
||||
switch appearance {
|
||||
case .system: "System"
|
||||
case .light: "Light"
|
||||
case .dark: "Dark"
|
||||
}
|
||||
}
|
||||
|
||||
private func watchImage(_ state: MobileWatchPreferenceState?) -> String {
|
||||
switch state {
|
||||
case .ready: "applewatch.radiowaves.left.and.right"
|
||||
case .pending: "arrow.triangle.2.circlepath"
|
||||
case .notPaired, .appNotInstalled: "applewatch.slash"
|
||||
default: "applewatch"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func authenticationDidChange() {
|
||||
refreshState()
|
||||
}
|
||||
@@ -1244,8 +1479,38 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
|
||||
private func refreshState() {
|
||||
state = try? authentication?.state()
|
||||
gitIdentity = try? authentication?.gitIdentity()
|
||||
preferences = try? authentication?.preferences(
|
||||
watchSupported: WCSession.isSupported(),
|
||||
watchPaired: watchSession?.isPaired ?? false,
|
||||
watchAppInstalled: watchSession?.isWatchAppInstalled ?? false
|
||||
)
|
||||
if let appearance = preferences?.appearance {
|
||||
tabBarController?.overrideUserInterfaceStyle = switch appearance {
|
||||
case .system: .unspecified
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
}
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
nonisolated func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: Error?
|
||||
) {
|
||||
Task { @MainActor [weak self] in self?.refreshState() }
|
||||
}
|
||||
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
nonisolated func sessionWatchStateDidChange(_ session: WCSession) {
|
||||
Task { @MainActor [weak self] in self?.refreshState() }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
Reference in New Issue
Block a user