Synchronize selected TOTP entries to Apple Watch (#55)

This commit is contained in:
2026-08-16 13:47:31 +02:00
parent affdb55519
commit a055c93b86
12 changed files with 1888 additions and 98 deletions

View File

@@ -14,11 +14,161 @@ extension Notification.Name {
static let ironStorageWatchSnapshotDidChange = Notification.Name(
"de.rfc1437.ironstorage.watch-snapshot-did-change"
)
static let ironStorageWatchStatusDidChange = Notification.Name(
"de.rfc1437.ironstorage.watch-status-did-change"
)
static let ironStorageKeyMaterialDidChange = Notification.Name(
"de.rfc1437.ironstorage.key-material-did-change"
)
}
@MainActor
private final class WatchSnapshotCoordinator: NSObject, WCSessionDelegate {
private static let snapshotKey = "de.rfc1437.ironstorage.watch.snapshot"
private static let deliveredReceiptKey = "de.rfc1437.ironstorage.watch.delivered"
private let authentication: MobileAuthentication?
private var session: WCSession?
private var syncTask: Task<Void, Never>?
private var generation = 0
var isSupported: Bool { WCSession.isSupported() }
var isPaired: Bool { session?.isPaired ?? false }
var isWatchAppInstalled: Bool { session?.isWatchAppInstalled ?? false }
init(authentication: MobileAuthentication?) {
self.authentication = authentication
super.init()
for name in [
Notification.Name.ironStorageWatchSnapshotDidChange,
.ironStorageLocalStoreDidChange,
.ironStorageAuthenticationDidChange,
] {
NotificationCenter.default.addObserver(
self,
selector: #selector(synchronize),
name: name,
object: nil
)
}
guard WCSession.isSupported() else {
recordUnavailable(unpaired: false, detail: "Apple Watch connectivity is unavailable on this device.")
return
}
let session = WCSession.default
self.session = session
session.delegate = self
session.activate()
}
deinit {
syncTask?.cancel()
session?.delegate = nil
NotificationCenter.default.removeObserver(self)
}
@objc func synchronize() {
generation += 1
syncTask?.cancel()
guard let authentication else { return }
guard let session, session.activationState == .activated else { return }
guard session.isPaired else {
try? session.updateApplicationContext([:])
recordUnavailable(unpaired: true, detail: "Pair an Apple Watch to synchronize selected TOTP entries.")
return
}
guard session.isWatchAppInstalled, let directory = session.watchDirectoryURL else {
try? session.updateApplicationContext([:])
recordUnavailable(unpaired: false, detail: "Install IronStorage on the paired Apple Watch.")
return
}
let pairingIdentity = directory.lastPathComponent
let current = generation
syncTask = Task { [weak self] in
let result = await Task.detached(priority: .utility) {
Result { try authentication.prepareWatchSnapshot(platformPairingIdentity: pairingIdentity) }
}.value
guard !Task.isCancelled, let self, current == generation else { return }
switch result {
case var .success(transfer):
var snapshot = Data(transfer.snapshot)
defer {
snapshot.resetBytes(in: 0..<snapshot.count)
transfer.snapshot.removeAll(keepingCapacity: false)
}
do {
try session.updateApplicationContext([
Self.snapshotKey: snapshot,
Self.deliveredReceiptKey: Data(transfer.deliveredReceipt),
])
} catch {
_ = try? authentication.failWatchSnapshot(
revision: transfer.revision,
detail: "The latest Apple Watch snapshot could not be queued."
)
}
notifyStatusChanged()
case .failure:
// A locked GPG key leaves the previous replacement pending. Authentication
// changes trigger a fresh attempt without persisting snapshot bytes in Swift.
notifyStatusChanged()
}
}
}
private func recordUnavailable(unpaired: Bool, detail: String) {
guard let authentication else { return }
_ = try? authentication.setWatchSnapshotUnavailable(unpaired: unpaired, detail: detail)
notifyStatusChanged()
}
private func notifyStatusChanged() {
NotificationCenter.default.post(name: .ironStorageWatchStatusDidChange, object: nil)
}
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor [weak self] in self?.synchronize() }
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {
Task { @MainActor [weak self] in
try? session.updateApplicationContext([:])
self?.recordUnavailable(unpaired: true, detail: "The active Apple Watch changed; a fresh snapshot is required.")
}
}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
nonisolated func sessionWatchStateDidChange(_ session: WCSession) {
Task { @MainActor [weak self] in self?.synchronize() }
}
nonisolated private func acknowledge(_ userInfo: [String: Any]) {
guard
let receipt = userInfo["de.rfc1437.ironstorage.watch.delivered"] as? Data
else { return }
Task { @MainActor [weak self] in
guard let self, let authentication else { return }
_ = try? authentication.acknowledgeWatchSnapshot(receipt: receipt)
notifyStatusChanged()
}
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
acknowledge(userInfo)
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
acknowledge(message)
}
}
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
@@ -73,6 +223,7 @@ private protocol MobileTabRoot: AnyObject {
private final class AppContext: NSObject, UITabBarControllerDelegate {
private let tabs = UITabBarController()
private let authentication = try? mobileAuthentication()
private lazy var watchSnapshot = WatchSnapshotCoordinator(authentication: authentication)
private var navigationControllers: [UINavigationController] = []
private var restoreTask: Task<Void, Never>?
private var authenticationMonitor: Task<Void, Never>?
@@ -83,6 +234,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
}
func makeRootController() -> UIViewController {
watchSnapshot.synchronize()
let shell = mobileShellFixture(state: .loading)
navigationControllers = shell.pages.map { page in
let root: UIViewController = switch page.tab {
@@ -93,7 +245,11 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
case .totp:
TotpListViewController(shellPage: page, authentication: authentication)
case .preferences:
PreferencesViewController(page: page, authentication: authentication)
PreferencesViewController(
page: page,
authentication: authentication,
watchSnapshot: watchSnapshot
)
case .home:
ShellViewController(page: page, authentication: authentication)
}
@@ -852,9 +1008,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
}
@MainActor
private final class PreferencesViewController: UITableViewController, MobileTabRoot,
WCSessionDelegate
{
private final class PreferencesViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.preferences
private var page: MobilePage
private let authentication: MobileAuthentication?
@@ -862,21 +1016,20 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
private var state: MobileAuthenticationState?
private var preferences: MobilePreferences?
private var gitIdentity: MobileGitIdentity?
private var watchSession: WCSession?
private let watchSnapshot: WatchSnapshotCoordinator
private var preferenceTask: Task<Void, Never>?
private var loadTask: Task<Void, Never>?
private var loadGeneration = 0
init(page: MobilePage, authentication: MobileAuthentication?) {
init(
page: MobilePage,
authentication: MobileAuthentication?,
watchSnapshot: WatchSnapshotCoordinator
) {
self.page = page
self.authentication = authentication
self.watchSnapshot = watchSnapshot
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(
@@ -891,6 +1044,12 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
name: .ironStorageAuthenticationDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageWatchStatusDidChange,
object: nil
)
}
@available(*, unavailable)
@@ -899,7 +1058,6 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
}
deinit {
watchSession?.delegate = nil
preferenceTask?.cancel()
loadTask?.cancel()
NotificationCenter.default.removeObserver(self)
@@ -1380,8 +1538,19 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
private func openWatchPreference() {
switch preferences?.watchState {
case .ready, .pending:
case .ready, .pending, .delivered, .current:
selectTotpTab()
case .failed:
let alert = UIAlertController(
title: "Apple Watch Synchronization Failed",
message: preferences?.watchDetail ?? "The latest snapshot could not be synchronized.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in
self?.watchSnapshot.synchronize()
})
present(alert, animated: true)
case .notPaired:
presentWatchGuidance(
title: "Pair an Apple Watch",
@@ -1429,6 +1598,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
switch state {
case .ready: "applewatch.radiowaves.left.and.right"
case .pending: "arrow.triangle.2.circlepath"
case .delivered: "arrow.down.circle"
case .current: "checkmark.circle.fill"
case .failed: "exclamationmark.triangle"
case .notPaired, .appNotInstalled: "applewatch.slash"
default: "applewatch"
}
@@ -1480,9 +1652,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
state = try? authentication?.state()
gitIdentity = try? authentication?.gitIdentity()
preferences = try? authentication?.preferences(
watchSupported: WCSession.isSupported(),
watchPaired: watchSession?.isPaired ?? false,
watchAppInstalled: watchSession?.isWatchAppInstalled ?? false
watchSupported: watchSnapshot.isSupported,
watchPaired: watchSnapshot.isPaired,
watchAppInstalled: watchSnapshot.isWatchAppInstalled
)
if let appearance = preferences?.appearance {
tabBarController?.overrideUserInterfaceStyle = switch appearance {
@@ -1494,23 +1666,6 @@ private final class PreferencesViewController: UITableViewController, MobileTabR
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
@@ -2220,6 +2375,12 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
name: .ironStorageWatchSnapshotDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(localStoreDidChange),
name: .ironStorageWatchStatusDidChange,
object: nil
)
refreshState()
}
@@ -2691,8 +2852,6 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
object: authentication
)
refreshState()
} else {
showUnavailable(title: failure.title, detail: failure.detail, image: "exclamationmark.triangle")
}
presentAuthenticationFailure(failure)
}
@@ -2870,6 +3029,12 @@ private final class TotpDetailViewController: UITableViewController {
name: .ironStorageLocalStoreDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(entryDidChange),
name: .ironStorageWatchStatusDidChange,
object: nil
)
apply(detail)
startTimer()
}