Implement iPhone and Apple Watch preferences (#53)

This commit is contained in:
2026-08-15 22:37:31 +02:00
parent 81886756a2
commit affdb55519
15 changed files with 1409 additions and 60 deletions

View File

@@ -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