Implement Apple Watch TOTP interface (#57)

This commit is contained in:
2026-08-16 14:38:16 +02:00
parent df1d49339c
commit e54d91a83a
6 changed files with 789 additions and 126 deletions

View File

@@ -1,3 +1,4 @@
import Combine
import Security
import SwiftUI
import WatchConnectivity
@@ -64,15 +65,21 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
private static let snapshotKey = "de.rfc1437.ironstorage.watch.snapshot"
private static let receiptKey = "de.rfc1437.ironstorage.watch.delivered"
@Published private(set) var records: [WatchTotpRecord] = []
@Published private(set) var presentation: WatchPresentation?
private let core = watchCore()
override init() {
super.init()
restoreSnapshot()
guard WCSession.isSupported() else { return }
guard WCSession.isSupported() else {
try? core.syncUnavailable()
refreshPresentation()
return
}
let session = WCSession.default
session.delegate = self
try? core.syncStarted()
refreshPresentation()
session.activate()
}
@@ -82,7 +89,7 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
func sceneBecameInactive() {
try? core.protectedDataUnavailable()
records.removeAll(keepingCapacity: false)
refreshPresentation()
}
private func restoreSnapshot() {
@@ -94,15 +101,20 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
} else {
try core.noPersistedSnapshot()
}
refreshRecords()
refreshPresentation()
} catch {
try? core.protectedDataUnavailable()
records.removeAll(keepingCapacity: false)
try? core.syncFailed()
refreshPresentation()
}
}
private func receive(_ applicationContext: [String: Any], session: WCSession) {
guard var snapshot = applicationContext[Self.snapshotKey] as? Data else { return }
try? core.syncStarted()
guard var snapshot = applicationContext[Self.snapshotKey] as? Data else {
try? core.syncFinished()
refreshPresentation()
return
}
defer { snapshot.resetBytes(in: 0..<snapshot.count) }
do {
let update = try core.applySnapshot(snapshot: snapshot)
@@ -114,7 +126,7 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
case .delete:
try SecureSnapshotStore.delete()
}
refreshRecords()
refreshPresentation()
guard !update.receipt.isEmpty else { return }
let acknowledgement = [Self.receiptKey: update.receipt]
if session.isReachable {
@@ -125,15 +137,15 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
session.transferUserInfo(acknowledgement)
}
} catch {
try? core.noPersistedSnapshot()
records.removeAll(keepingCapacity: false)
try? core.syncFailed()
refreshPresentation()
}
}
private func refreshRecords() {
records = (try? core.recordsAt(
unixSeconds: UInt64(Date().timeIntervalSince1970)
)) ?? []
func refreshPresentation(at date: Date = .now) {
presentation = try? core.presentationAt(
unixSeconds: UInt64(date.timeIntervalSince1970)
)
}
nonisolated func session(
@@ -141,9 +153,14 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
guard activationState == .activated, error == nil else { return }
Task { @MainActor [weak self] in
self?.receive(session.receivedApplicationContext, session: session)
guard let self else { return }
guard activationState == .activated, error == nil else {
try? self.core.syncUnavailable()
self.refreshPresentation()
return
}
self.receive(session.receivedApplicationContext, session: session)
}
}
@@ -157,6 +174,131 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
}
}
private struct WatchRootView: View {
@ObservedObject var transport: WatchSnapshotTransport
private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
NavigationStack {
if let presentation = transport.presentation {
if presentation.records.isEmpty {
WatchStateView(presentation: presentation)
} else {
List {
if presentation.state == .stale {
WatchStatusRow(presentation: presentation)
}
ForEach(presentation.records, id: \.path) { record in
NavigationLink {
WatchTotpDetail(path: record.path, transport: transport)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(record.issuer ?? record.account)
.font(.headline)
Text(record.account)
.font(.caption)
.foregroundStyle(.secondary)
Text(record.code)
.font(.system(.title3, design: .rounded, weight: .semibold))
.monospacedDigit()
.privacySensitive()
}
}
}
}
.navigationTitle(presentation.title)
}
}
}
.onReceive(clock) { transport.refreshPresentation(at: $0) }
}
}
private struct WatchTotpDetail: View {
let path: String
@ObservedObject var transport: WatchSnapshotTransport
private var record: WatchTotpRecord? {
transport.presentation?.records.first { $0.path == path }
}
var body: some View {
if let record {
ScrollView {
VStack(spacing: 8) {
Text(record.issuer ?? record.account)
.font(.headline)
.multilineTextAlignment(.center)
Text(record.account)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Text(record.code)
.font(.system(.largeTitle, design: .rounded, weight: .bold))
.monospacedDigit()
.minimumScaleFactor(0.7)
.lineLimit(1)
.privacySensitive()
ProgressView(
value: Double(record.period - min(record.remaining, record.period)),
total: Double(record.period)
)
.accessibilityLabel("Code validity")
.accessibilityValue("\(record.remaining) seconds remaining")
Text("\(record.remaining)s")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
.navigationTitle(record.account)
} else if let presentation = transport.presentation {
WatchStateView(presentation: presentation)
}
}
}
private struct WatchStateView: View {
let presentation: WatchPresentation
var body: some View {
ContentUnavailableView(
presentation.title,
systemImage: icon,
description: Text(presentation.detail)
)
}
private var icon: String {
switch presentation.state {
case .ready: "timer"
case .empty: "iphone.and.arrow.forward"
case .syncing: "arrow.triangle.2.circlepath"
case .stale: "clock.badge.exclamationmark"
case .locked: "lock"
case .unavailable: "iphone.slash"
case .error: "exclamationmark.triangle"
}
}
}
private struct WatchStatusRow: View {
let presentation: WatchPresentation
var body: some View {
Label {
VStack(alignment: .leading) {
Text(presentation.title).font(.headline)
Text(presentation.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "clock.badge.exclamationmark")
}
}
}
@main
struct IronStorageWatchApp: App {
@Environment(\.scenePhase) private var scenePhase
@@ -164,7 +306,7 @@ struct IronStorageWatchApp: App {
var body: some Scene {
WindowGroup {
ContentUnavailableView("No TOTP Codes", systemImage: "timer")
WatchRootView(transport: transport)
.onChange(of: scenePhase) { _, phase in
if phase == .active {
transport.sceneBecameActive()