Implement Apple Watch TOTP interface (#57)
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user