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

@@ -567,9 +567,17 @@ public protocol WatchCoreProtocol: AnyObject, Sendable {
func noPersistedSnapshot() throws func noPersistedSnapshot() throws
func presentationAt(unixSeconds: UInt64) throws -> WatchPresentation
func protectedDataUnavailable() throws func protectedDataUnavailable() throws
func recordsAt(unixSeconds: UInt64) throws -> [WatchTotpRecord] func syncFailed() throws
func syncFinished() throws
func syncStarted() throws
func syncUnavailable() throws
} }
open class WatchCore: WatchCoreProtocol, @unchecked Sendable { open class WatchCore: WatchCoreProtocol, @unchecked Sendable {
@@ -643,6 +651,16 @@ open func noPersistedSnapshot()throws {try rustCallWithError(FfiConverterTypeW
} }
} }
open func presentationAt(unixSeconds: UInt64)throws -> WatchPresentation {
return try FfiConverterTypeWatchPresentation_lift(try rustCallWithError(FfiConverterTypeWatchFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_watch_fn_method_watchcore_presentation_at(
self.uniffiCloneHandle(),
FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus
)
})
}
open func protectedDataUnavailable()throws {try rustCallWithError(FfiConverterTypeWatchFfiError_lift) { open func protectedDataUnavailable()throws {try rustCallWithError(FfiConverterTypeWatchFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
uniffi_ironstorage_watch_fn_method_watchcore_protected_data_unavailable( uniffi_ironstorage_watch_fn_method_watchcore_protected_data_unavailable(
@@ -651,14 +669,36 @@ open func protectedDataUnavailable()throws {try rustCallWithError(FfiConverter
} }
} }
open func recordsAt(unixSeconds: UInt64)throws -> [WatchTotpRecord] { open func syncFailed()throws {try rustCallWithError(FfiConverterTypeWatchFfiError_lift) {
return try FfiConverterSequenceTypeWatchTotpRecord.lift(try rustCallWithError(FfiConverterTypeWatchFfiError_lift) {
uniffiCallStatus in uniffiCallStatus in
uniffi_ironstorage_watch_fn_method_watchcore_records_at( uniffi_ironstorage_watch_fn_method_watchcore_sync_failed(
self.uniffiCloneHandle(), self.uniffiCloneHandle(),uniffiCallStatus
FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus
) )
}) }
}
open func syncFinished()throws {try rustCallWithError(FfiConverterTypeWatchFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_watch_fn_method_watchcore_sync_finished(
self.uniffiCloneHandle(),uniffiCallStatus
)
}
}
open func syncStarted()throws {try rustCallWithError(FfiConverterTypeWatchFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_watch_fn_method_watchcore_sync_started(
self.uniffiCloneHandle(),uniffiCallStatus
)
}
}
open func syncUnavailable()throws {try rustCallWithError(FfiConverterTypeWatchFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_watch_fn_method_watchcore_sync_unavailable(
self.uniffiCloneHandle(),uniffiCallStatus
)
}
} }
@@ -709,6 +749,68 @@ public func FfiConverterTypeWatchCore_lower(_ value: WatchCore) -> UInt64 {
public struct WatchPresentation: Equatable, Hashable {
public var state: WatchPresentationState
public var title: String
public var detail: String
public var records: [WatchTotpRecord]
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(state: WatchPresentationState, title: String, detail: String, records: [WatchTotpRecord]) {
self.state = state
self.title = title
self.detail = detail
self.records = records
}
}
#if compiler(>=6)
extension WatchPresentation: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeWatchPresentation: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WatchPresentation {
return
try WatchPresentation(
state: FfiConverterTypeWatchPresentationState.read(from: &buf),
title: FfiConverterString.read(from: &buf),
detail: FfiConverterString.read(from: &buf),
records: FfiConverterSequenceTypeWatchTotpRecord.read(from: &buf)
)
}
public static func write(_ value: WatchPresentation, into buf: inout [UInt8]) {
FfiConverterTypeWatchPresentationState.write(value.state, into: &buf)
FfiConverterString.write(value.title, into: &buf)
FfiConverterString.write(value.detail, into: &buf)
FfiConverterSequenceTypeWatchTotpRecord.write(value.records, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeWatchPresentation_lift(_ buf: RustBuffer) throws -> WatchPresentation {
return try FfiConverterTypeWatchPresentation.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeWatchPresentation_lower(_ value: WatchPresentation) -> RustBuffer {
return FfiConverterTypeWatchPresentation.lower(value)
}
public struct WatchSnapshotUpdate: Equatable, Hashable { public struct WatchSnapshotUpdate: Equatable, Hashable {
public var apply: WatchSnapshotApply public var apply: WatchSnapshotApply
public var persistence: WatchPersistenceAction public var persistence: WatchPersistenceAction
@@ -782,16 +884,18 @@ public struct WatchTotpRecord: Equatable, Hashable {
public var code: String public var code: String
public var period: UInt64 public var period: UInt64
public var validUntil: UInt64 public var validUntil: UInt64
public var remaining: UInt64
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // declare one manually.
public init(path: String, issuer: String?, account: String, code: String, period: UInt64, validUntil: UInt64) { public init(path: String, issuer: String?, account: String, code: String, period: UInt64, validUntil: UInt64, remaining: UInt64) {
self.path = path self.path = path
self.issuer = issuer self.issuer = issuer
self.account = account self.account = account
self.code = code self.code = code
self.period = period self.period = period
self.validUntil = validUntil self.validUntil = validUntil
self.remaining = remaining
} }
@@ -815,7 +919,8 @@ public struct FfiConverterTypeWatchTotpRecord: FfiConverterRustBuffer {
account: FfiConverterString.read(from: &buf), account: FfiConverterString.read(from: &buf),
code: FfiConverterString.read(from: &buf), code: FfiConverterString.read(from: &buf),
period: FfiConverterUInt64.read(from: &buf), period: FfiConverterUInt64.read(from: &buf),
validUntil: FfiConverterUInt64.read(from: &buf) validUntil: FfiConverterUInt64.read(from: &buf),
remaining: FfiConverterUInt64.read(from: &buf)
) )
} }
@@ -826,6 +931,7 @@ public struct FfiConverterTypeWatchTotpRecord: FfiConverterRustBuffer {
FfiConverterString.write(value.code, into: &buf) FfiConverterString.write(value.code, into: &buf)
FfiConverterUInt64.write(value.period, into: &buf) FfiConverterUInt64.write(value.period, into: &buf)
FfiConverterUInt64.write(value.validUntil, into: &buf) FfiConverterUInt64.write(value.validUntil, into: &buf)
FfiConverterUInt64.write(value.remaining, into: &buf)
} }
} }
@@ -994,6 +1100,107 @@ public func FfiConverterTypeWatchPersistenceAction_lower(_ value: WatchPersisten
public enum WatchPresentationState: Equatable, Hashable {
case ready
case empty
case syncing
case stale
case locked
case unavailable
case error
}
#if compiler(>=6)
extension WatchPresentationState: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeWatchPresentationState: FfiConverterRustBuffer {
typealias SwiftType = WatchPresentationState
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WatchPresentationState {
let variant: Int32 = try readInt(&buf)
switch variant {
case 1: return .ready
case 2: return .empty
case 3: return .syncing
case 4: return .stale
case 5: return .locked
case 6: return .unavailable
case 7: return .error
default: throw UniffiInternalError.unexpectedEnumCase
}
}
public static func write(_ value: WatchPresentationState, into buf: inout [UInt8]) {
switch value {
case .ready:
writeInt(&buf, Int32(1))
case .empty:
writeInt(&buf, Int32(2))
case .syncing:
writeInt(&buf, Int32(3))
case .stale:
writeInt(&buf, Int32(4))
case .locked:
writeInt(&buf, Int32(5))
case .unavailable:
writeInt(&buf, Int32(6))
case .error:
writeInt(&buf, Int32(7))
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeWatchPresentationState_lift(_ buf: RustBuffer) throws -> WatchPresentationState {
return try FfiConverterTypeWatchPresentationState.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeWatchPresentationState_lower(_ value: WatchPresentationState) -> RustBuffer {
return FfiConverterTypeWatchPresentationState.lower(value)
}
public enum WatchSnapshotApply: Equatable, Hashable { public enum WatchSnapshotApply: Equatable, Hashable {
case replaced case replaced
@@ -1183,10 +1390,22 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_watch_checksum_method_watchcore_no_persisted_snapshot() != 32214) { if (uniffi_ironstorage_watch_checksum_method_watchcore_no_persisted_snapshot() != 32214) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_ironstorage_watch_checksum_method_watchcore_presentation_at() != 27053) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_watch_checksum_method_watchcore_protected_data_unavailable() != 42217) { if (uniffi_ironstorage_watch_checksum_method_watchcore_protected_data_unavailable() != 42217) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_ironstorage_watch_checksum_method_watchcore_records_at() != 48946) { if (uniffi_ironstorage_watch_checksum_method_watchcore_sync_failed() != 27399) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_watch_checksum_method_watchcore_sync_finished() != 42146) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_watch_checksum_method_watchcore_sync_started() != 37956) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_watch_checksum_method_watchcore_sync_unavailable() != 38367) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }

View File

@@ -263,14 +263,34 @@ RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_apply_snapshot(uint64_t
void uniffi_ironstorage_watch_fn_method_watchcore_no_persisted_snapshot(uint64_t ptr, RustCallStatus *_Nonnull out_status void uniffi_ironstorage_watch_fn_method_watchcore_no_persisted_snapshot(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PRESENTATION_AT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PRESENTATION_AT
RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_presentation_at(uint64_t ptr, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
void uniffi_ironstorage_watch_fn_method_watchcore_protected_data_unavailable(uint64_t ptr, RustCallStatus *_Nonnull out_status void uniffi_ironstorage_watch_fn_method_watchcore_protected_data_unavailable(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_RECORDS_AT #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FAILED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_RECORDS_AT #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FAILED
RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_records_at(uint64_t ptr, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status void uniffi_ironstorage_watch_fn_method_watchcore_sync_failed(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FINISHED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FINISHED
void uniffi_ironstorage_watch_fn_method_watchcore_sync_finished(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_STARTED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_STARTED
void uniffi_ironstorage_watch_fn_method_watchcore_sync_started(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_UNAVAILABLE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_UNAVAILABLE
void uniffi_ironstorage_watch_fn_method_watchcore_sync_unavailable(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FUNC_WATCH_CORE #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FUNC_WATCH_CORE
@@ -555,6 +575,12 @@ uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_apply_snapshot(void
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_no_persisted_snapshot(void uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_no_persisted_snapshot(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PRESENTATION_AT
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PRESENTATION_AT
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_presentation_at(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
@@ -563,9 +589,27 @@ uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_protected_data_unava
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_RECORDS_AT #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FAILED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_RECORDS_AT #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FAILED
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_records_at(void uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_failed(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FINISHED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FINISHED
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_finished(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_STARTED
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_STARTED
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_started(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_UNAVAILABLE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_UNAVAILABLE
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_unavailable(void
); );
#endif #endif

View File

@@ -1,3 +1,4 @@
import Combine
import Security import Security
import SwiftUI import SwiftUI
import WatchConnectivity 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 snapshotKey = "de.rfc1437.ironstorage.watch.snapshot"
private static let receiptKey = "de.rfc1437.ironstorage.watch.delivered" 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() private let core = watchCore()
override init() { override init() {
super.init() super.init()
restoreSnapshot() restoreSnapshot()
guard WCSession.isSupported() else { return } guard WCSession.isSupported() else {
try? core.syncUnavailable()
refreshPresentation()
return
}
let session = WCSession.default let session = WCSession.default
session.delegate = self session.delegate = self
try? core.syncStarted()
refreshPresentation()
session.activate() session.activate()
} }
@@ -82,7 +89,7 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
func sceneBecameInactive() { func sceneBecameInactive() {
try? core.protectedDataUnavailable() try? core.protectedDataUnavailable()
records.removeAll(keepingCapacity: false) refreshPresentation()
} }
private func restoreSnapshot() { private func restoreSnapshot() {
@@ -94,15 +101,20 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
} else { } else {
try core.noPersistedSnapshot() try core.noPersistedSnapshot()
} }
refreshRecords() refreshPresentation()
} catch { } catch {
try? core.protectedDataUnavailable() try? core.syncFailed()
records.removeAll(keepingCapacity: false) refreshPresentation()
} }
} }
private func receive(_ applicationContext: [String: Any], session: WCSession) { 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) } defer { snapshot.resetBytes(in: 0..<snapshot.count) }
do { do {
let update = try core.applySnapshot(snapshot: snapshot) let update = try core.applySnapshot(snapshot: snapshot)
@@ -114,7 +126,7 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
case .delete: case .delete:
try SecureSnapshotStore.delete() try SecureSnapshotStore.delete()
} }
refreshRecords() refreshPresentation()
guard !update.receipt.isEmpty else { return } guard !update.receipt.isEmpty else { return }
let acknowledgement = [Self.receiptKey: update.receipt] let acknowledgement = [Self.receiptKey: update.receipt]
if session.isReachable { if session.isReachable {
@@ -125,15 +137,15 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
session.transferUserInfo(acknowledgement) session.transferUserInfo(acknowledgement)
} }
} catch { } catch {
try? core.noPersistedSnapshot() try? core.syncFailed()
records.removeAll(keepingCapacity: false) refreshPresentation()
} }
} }
private func refreshRecords() { func refreshPresentation(at date: Date = .now) {
records = (try? core.recordsAt( presentation = try? core.presentationAt(
unixSeconds: UInt64(Date().timeIntervalSince1970) unixSeconds: UInt64(date.timeIntervalSince1970)
)) ?? [] )
} }
nonisolated func session( nonisolated func session(
@@ -141,9 +153,14 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio
activationDidCompleteWith activationState: WCSessionActivationState, activationDidCompleteWith activationState: WCSessionActivationState,
error: Error? error: Error?
) { ) {
guard activationState == .activated, error == nil else { return }
Task { @MainActor [weak self] in 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 @main
struct IronStorageWatchApp: App { struct IronStorageWatchApp: App {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@@ -164,7 +306,7 @@ struct IronStorageWatchApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentUnavailableView("No TOTP Codes", systemImage: "timer") WatchRootView(transport: transport)
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
if phase == .active { if phase == .active {
transport.sceneBecameActive() transport.sceneBecameActive()

View File

@@ -327,6 +327,7 @@ pub struct WatchTotpRecord {
code: SecretBytes, code: SecretBytes,
period: u64, period: u64,
valid_until: u64, valid_until: u64,
remaining: u64,
} }
impl WatchTotpRecord { impl WatchTotpRecord {
@@ -348,6 +349,9 @@ impl WatchTotpRecord {
pub fn valid_until(&self) -> u64 { pub fn valid_until(&self) -> u64 {
self.valid_until self.valid_until
} }
pub fn remaining(&self) -> u64 {
self.remaining
}
pub fn remaining_at(&self, unix_seconds: u64) -> u64 { pub fn remaining_at(&self, unix_seconds: u64) -> u64 {
self.valid_until.saturating_sub(unix_seconds) self.valid_until.saturating_sub(unix_seconds)
} }
@@ -363,14 +367,58 @@ impl fmt::Debug for WatchTotpRecord {
.field("code", &"[REDACTED]") .field("code", &"[REDACTED]")
.field("period", &self.period) .field("period", &self.period)
.field("valid_until", &self.valid_until) .field("valid_until", &self.valid_until)
.field("remaining", &self.remaining)
.finish() .finish()
} }
} }
#[derive(Default)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WatchPresentationState {
Ready,
Empty,
Syncing,
Stale,
Locked,
Unavailable,
Error,
}
pub struct WatchPresentation {
state: WatchPresentationState,
title: String,
detail: String,
records: Vec<WatchTotpRecord>,
}
impl WatchPresentation {
pub fn state(&self) -> WatchPresentationState {
self.state
}
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
pub fn records(&self) -> &[WatchTotpRecord] {
&self.records
}
}
pub struct WatchRuntime { pub struct WatchRuntime {
receiver: WatchSnapshotReceiver, receiver: WatchSnapshotReceiver,
protected_data_available: bool, protected_data_available: bool,
presentation_state: WatchPresentationState,
}
impl Default for WatchRuntime {
fn default() -> Self {
Self {
receiver: WatchSnapshotReceiver::default(),
protected_data_available: true,
presentation_state: WatchPresentationState::Syncing,
}
}
} }
impl WatchRuntime { impl WatchRuntime {
@@ -378,8 +426,21 @@ impl WatchRuntime {
&mut self, &mut self,
bytes: Vec<u8>, bytes: Vec<u8>,
) -> Result<WatchSnapshotUpdate, WatchSnapshotError> { ) -> Result<WatchSnapshotUpdate, WatchSnapshotError> {
let apply = self.receiver.apply(SecretBytes::new(bytes))?; let apply = match self.receiver.apply(SecretBytes::new(bytes)) {
Ok(apply) => apply,
Err(error) => {
self.presentation_state = WatchPresentationState::Error;
return Err(error);
}
};
self.protected_data_available = true; self.protected_data_available = true;
self.presentation_state = match apply {
WatchSnapshotApply::Revoked => WatchPresentationState::Empty,
WatchSnapshotApply::Stale => WatchPresentationState::Stale,
WatchSnapshotApply::Replaced
| WatchSnapshotApply::Duplicate
| WatchSnapshotApply::PairingChanged => WatchPresentationState::Ready,
};
let persistence = match apply { let persistence = match apply {
WatchSnapshotApply::Replaced | WatchSnapshotApply::PairingChanged => { WatchSnapshotApply::Replaced | WatchSnapshotApply::PairingChanged => {
WatchPersistenceAction::Replace WatchPersistenceAction::Replace
@@ -434,19 +495,95 @@ impl WatchRuntime {
code, code,
period: entry.period, period: entry.period,
valid_until, valid_until,
remaining: valid_until.saturating_sub(unix_seconds),
}) })
}) })
.collect() .collect()
} }
pub fn presentation_at(
&self,
unix_seconds: u64,
) -> Result<WatchPresentation, WatchSnapshotError> {
let records = if matches!(
self.presentation_state,
WatchPresentationState::Ready | WatchPresentationState::Stale
) {
self.records_at(unix_seconds)?
} else {
Vec::new()
};
let (title, detail) = match self.presentation_state {
WatchPresentationState::Ready => ("TOTP Codes", "Select an entry to view its code."),
WatchPresentationState::Empty => (
"No TOTP Codes",
"Select TOTP entries in IronStorage on your iPhone.",
),
WatchPresentationState::Syncing => (
"Syncing",
"Checking for TOTP entries shared by your iPhone.",
),
WatchPresentationState::Stale => (
"Update Delayed",
"Showing saved codes; the received update was older.",
),
WatchPresentationState::Locked => (
"Watch Locked",
"Unlock your Watch to access shared TOTP codes.",
),
WatchPresentationState::Unavailable => (
"Sync Unavailable",
"Open IronStorage on your iPhone to set up Watch sync.",
),
WatchPresentationState::Error => (
"Codes Unavailable",
"Open IronStorage on your iPhone and share the entries again.",
),
};
Ok(WatchPresentation {
state: self.presentation_state,
title: title.to_owned(),
detail: detail.to_owned(),
records,
})
}
pub fn sync_started(&mut self) {
if self.receiver.current().is_none() && self.protected_data_available {
self.presentation_state = WatchPresentationState::Syncing;
}
}
pub fn sync_finished(&mut self) {
if self.receiver.current().is_none()
&& self.protected_data_available
&& self.presentation_state == WatchPresentationState::Syncing
{
self.presentation_state = WatchPresentationState::Empty;
}
}
pub fn sync_unavailable(&mut self) {
if self.receiver.current().is_none() && self.protected_data_available {
self.presentation_state = WatchPresentationState::Unavailable;
}
}
pub fn sync_failed(&mut self) {
self.receiver.clear_secrets();
self.presentation_state = WatchPresentationState::Error;
}
pub fn protected_data_unavailable(&mut self) { pub fn protected_data_unavailable(&mut self) {
self.receiver.clear_secrets(); self.receiver.clear_secrets();
self.protected_data_available = false; self.protected_data_available = false;
self.presentation_state = WatchPresentationState::Locked;
} }
pub fn no_persisted_snapshot(&mut self) { pub fn no_persisted_snapshot(&mut self) {
self.receiver.revoke(); self.receiver.revoke();
self.protected_data_available = true; self.protected_data_available = true;
self.presentation_state = WatchPresentationState::Empty;
} }
} }

View File

@@ -4,8 +4,8 @@ use std::fs;
use ironstorage::{ use ironstorage::{
mobile_watch::{ mobile_watch::{
MobileWatchSnapshotState, WatchPersistenceAction, WatchRuntime, WatchSnapshotApply, MobileWatchSnapshotState, WatchPersistenceAction, WatchPresentationState, WatchRuntime,
WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender, WatchSnapshotApply, WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender,
}, },
otp::OtpAlgorithm, otp::OtpAlgorithm,
repository::{EntryPath, SecretBytes}, repository::{EntryPath, SecretBytes},
@@ -184,6 +184,22 @@ fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> T
)?; )?;
let mut runtime = WatchRuntime::default(); let mut runtime = WatchRuntime::default();
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Syncing
);
runtime.sync_finished();
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Empty
);
runtime.sync_started();
runtime.sync_unavailable();
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Unavailable
);
runtime.sync_started();
let update = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?; let update = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(update.apply(), WatchSnapshotApply::Replaced); assert_eq!(update.apply(), WatchSnapshotApply::Replaced);
assert_eq!(update.persistence(), WatchPersistenceAction::Replace); assert_eq!(update.persistence(), WatchPersistenceAction::Replace);
@@ -196,9 +212,16 @@ fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> T
assert_eq!(records[0].code().expose(), b"94287082"); assert_eq!(records[0].code().expose(), b"94287082");
assert_eq!(records[0].valid_until(), 60); assert_eq!(records[0].valid_until(), 60);
assert_eq!(records[0].remaining_at(59), 1); assert_eq!(records[0].remaining_at(59), 1);
let presentation = runtime.presentation_at(59)?;
assert_eq!(presentation.state(), WatchPresentationState::Ready);
assert_eq!(presentation.records()[0].remaining(), 1);
runtime.protected_data_unavailable(); runtime.protected_data_unavailable();
assert!(runtime.records_at(59).is_err()); assert!(runtime.records_at(59).is_err());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Locked
);
let restored = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?; let restored = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(restored.persistence(), WatchPersistenceAction::Replace); assert_eq!(restored.persistence(), WatchPersistenceAction::Replace);
@@ -209,5 +232,19 @@ fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> T
assert_eq!(revoked.apply(), WatchSnapshotApply::Revoked); assert_eq!(revoked.apply(), WatchSnapshotApply::Revoked);
assert_eq!(revoked.persistence(), WatchPersistenceAction::Delete); assert_eq!(revoked.persistence(), WatchPersistenceAction::Delete);
assert!(runtime.records_at(59)?.is_empty()); assert!(runtime.records_at(59)?.is_empty());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Empty
);
runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Stale
);
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Error
);
Ok(()) Ok(())
} }

View File

@@ -10,7 +10,8 @@ use std::{
}; };
use ironstorage::mobile_watch::{ use ironstorage::mobile_watch::{
WatchPersistenceAction as StoragePersistenceAction, WatchRuntime as StorageWatchRuntime, WatchPersistenceAction as StoragePersistenceAction, WatchPresentation as StoragePresentation,
WatchPresentationState as StoragePresentationState, WatchRuntime as StorageWatchRuntime,
WatchSnapshotApply as StorageSnapshotApply, WatchSnapshotError as StorageWatchError, WatchSnapshotApply as StorageSnapshotApply, WatchSnapshotError as StorageWatchError,
WatchSnapshotUpdate as StorageSnapshotUpdate, WatchTotpRecord as StorageTotpRecord, WatchSnapshotUpdate as StorageSnapshotUpdate, WatchTotpRecord as StorageTotpRecord,
}; };
@@ -83,10 +84,11 @@ pub struct WatchTotpRecord {
pub code: String, pub code: String,
pub period: u64, pub period: u64,
pub valid_until: u64, pub valid_until: u64,
pub remaining: u64,
} }
impl From<StorageTotpRecord> for WatchTotpRecord { impl From<&StorageTotpRecord> for WatchTotpRecord {
fn from(value: StorageTotpRecord) -> Self { fn from(value: &StorageTotpRecord) -> Self {
Self { Self {
path: value.path().to_owned(), path: value.path().to_owned(),
issuer: value.issuer().map(str::to_owned), issuer: value.issuer().map(str::to_owned),
@@ -95,6 +97,51 @@ impl From<StorageTotpRecord> for WatchTotpRecord {
.expect("storage-generated TOTP codes are ASCII"), .expect("storage-generated TOTP codes are ASCII"),
period: value.period(), period: value.period(),
valid_until: value.valid_until(), valid_until: value.valid_until(),
remaining: value.remaining(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum WatchPresentationState {
Ready,
Empty,
Syncing,
Stale,
Locked,
Unavailable,
Error,
}
impl From<StoragePresentationState> for WatchPresentationState {
fn from(value: StoragePresentationState) -> Self {
match value {
StoragePresentationState::Ready => Self::Ready,
StoragePresentationState::Empty => Self::Empty,
StoragePresentationState::Syncing => Self::Syncing,
StoragePresentationState::Stale => Self::Stale,
StoragePresentationState::Locked => Self::Locked,
StoragePresentationState::Unavailable => Self::Unavailable,
StoragePresentationState::Error => Self::Error,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
pub struct WatchPresentation {
pub state: WatchPresentationState,
pub title: String,
pub detail: String,
pub records: Vec<WatchTotpRecord>,
}
impl From<StoragePresentation> for WatchPresentation {
fn from(value: StoragePresentation) -> Self {
Self {
state: value.state().into(),
title: value.title().to_owned(),
detail: value.detail().to_owned(),
records: value.records().iter().map(WatchTotpRecord::from).collect(),
} }
} }
} }
@@ -138,15 +185,44 @@ impl WatchCore {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn records_at(&self, unix_seconds: u64) -> Result<Vec<WatchTotpRecord>, WatchFfiError> { pub fn presentation_at(&self, unix_seconds: u64) -> Result<WatchPresentation, WatchFfiError> {
self.runtime self.runtime
.lock() .lock()
.map_err(|_| lock_error())? .map_err(|_| lock_error())?
.records_at(unix_seconds) .presentation_at(unix_seconds)
.map(|records| records.into_iter().map(Into::into).collect()) .map(Into::into)
.map_err(Into::into) .map_err(Into::into)
} }
pub fn sync_started(&self) -> Result<(), WatchFfiError> {
self.runtime
.lock()
.map_err(|_| lock_error())?
.sync_started();
Ok(())
}
pub fn sync_finished(&self) -> Result<(), WatchFfiError> {
self.runtime
.lock()
.map_err(|_| lock_error())?
.sync_finished();
Ok(())
}
pub fn sync_unavailable(&self) -> Result<(), WatchFfiError> {
self.runtime
.lock()
.map_err(|_| lock_error())?
.sync_unavailable();
Ok(())
}
pub fn sync_failed(&self) -> Result<(), WatchFfiError> {
self.runtime.lock().map_err(|_| lock_error())?.sync_failed();
Ok(())
}
pub fn protected_data_unavailable(&self) -> Result<(), WatchFfiError> { pub fn protected_data_unavailable(&self) -> Result<(), WatchFfiError> {
self.runtime self.runtime
.lock() .lock()
@@ -183,8 +259,16 @@ mod tests {
fn bridge_masks_records_when_protected_data_is_unavailable() { fn bridge_masks_records_when_protected_data_is_unavailable() {
let core = super::watch_core(); let core = super::watch_core();
core.no_persisted_snapshot().expect("available Keychain"); core.no_persisted_snapshot().expect("available Keychain");
assert!(core.records_at(59).expect("empty snapshot").is_empty()); assert!(
core.presentation_at(59)
.expect("empty snapshot")
.records
.is_empty()
);
core.protected_data_unavailable().expect("lock transition"); core.protected_data_unavailable().expect("lock transition");
assert!(core.records_at(59).is_err()); assert_eq!(
core.presentation_at(59).expect("locked presentation").state,
super::WatchPresentationState::Locked
);
} }
} }