Implement Apple Watch TOTP interface (#57)
This commit is contained in:
@@ -567,9 +567,17 @@ public protocol WatchCoreProtocol: AnyObject, Sendable {
|
||||
|
||||
func noPersistedSnapshot() throws
|
||||
|
||||
func presentationAt(unixSeconds: UInt64) throws -> WatchPresentation
|
||||
|
||||
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 {
|
||||
@@ -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) {
|
||||
uniffiCallStatus in
|
||||
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] {
|
||||
return try FfiConverterSequenceTypeWatchTotpRecord.lift(try rustCallWithError(FfiConverterTypeWatchFfiError_lift) {
|
||||
open func syncFailed()throws {try rustCallWithError(FfiConverterTypeWatchFfiError_lift) {
|
||||
uniffiCallStatus in
|
||||
uniffi_ironstorage_watch_fn_method_watchcore_records_at(
|
||||
self.uniffiCloneHandle(),
|
||||
FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus
|
||||
uniffi_ironstorage_watch_fn_method_watchcore_sync_failed(
|
||||
self.uniffiCloneHandle(),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 var apply: WatchSnapshotApply
|
||||
public var persistence: WatchPersistenceAction
|
||||
@@ -782,16 +884,18 @@ public struct WatchTotpRecord: Equatable, Hashable {
|
||||
public var code: String
|
||||
public var period: UInt64
|
||||
public var validUntil: UInt64
|
||||
public var remaining: UInt64
|
||||
|
||||
// Default memberwise initializers are never public by default, so we
|
||||
// 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.issuer = issuer
|
||||
self.account = account
|
||||
self.code = code
|
||||
self.period = period
|
||||
self.validUntil = validUntil
|
||||
self.remaining = remaining
|
||||
}
|
||||
|
||||
|
||||
@@ -815,7 +919,8 @@ public struct FfiConverterTypeWatchTotpRecord: FfiConverterRustBuffer {
|
||||
account: FfiConverterString.read(from: &buf),
|
||||
code: FfiConverterString.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)
|
||||
FfiConverterUInt64.write(value.period, 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 {
|
||||
|
||||
case replaced
|
||||
@@ -1183,10 +1390,22 @@ private let initializationResult: InitializationResult = {
|
||||
if (uniffi_ironstorage_watch_checksum_method_watchcore_no_persisted_snapshot() != 32214) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
#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
|
||||
#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
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_RECORDS_AT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_RECORDS_AT
|
||||
RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_records_at(uint64_t ptr, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FAILED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FAILED
|
||||
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
|
||||
#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
|
||||
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
|
||||
#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
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_RECORDS_AT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_RECORDS_AT
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_records_at(void
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FAILED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FAILED
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -327,6 +327,7 @@ pub struct WatchTotpRecord {
|
||||
code: SecretBytes,
|
||||
period: u64,
|
||||
valid_until: u64,
|
||||
remaining: u64,
|
||||
}
|
||||
|
||||
impl WatchTotpRecord {
|
||||
@@ -348,6 +349,9 @@ impl WatchTotpRecord {
|
||||
pub fn valid_until(&self) -> u64 {
|
||||
self.valid_until
|
||||
}
|
||||
pub fn remaining(&self) -> u64 {
|
||||
self.remaining
|
||||
}
|
||||
pub fn remaining_at(&self, unix_seconds: u64) -> u64 {
|
||||
self.valid_until.saturating_sub(unix_seconds)
|
||||
}
|
||||
@@ -363,14 +367,58 @@ impl fmt::Debug for WatchTotpRecord {
|
||||
.field("code", &"[REDACTED]")
|
||||
.field("period", &self.period)
|
||||
.field("valid_until", &self.valid_until)
|
||||
.field("remaining", &self.remaining)
|
||||
.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 {
|
||||
receiver: WatchSnapshotReceiver,
|
||||
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 {
|
||||
@@ -378,8 +426,21 @@ impl WatchRuntime {
|
||||
&mut self,
|
||||
bytes: Vec<u8>,
|
||||
) -> 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.presentation_state = match apply {
|
||||
WatchSnapshotApply::Revoked => WatchPresentationState::Empty,
|
||||
WatchSnapshotApply::Stale => WatchPresentationState::Stale,
|
||||
WatchSnapshotApply::Replaced
|
||||
| WatchSnapshotApply::Duplicate
|
||||
| WatchSnapshotApply::PairingChanged => WatchPresentationState::Ready,
|
||||
};
|
||||
let persistence = match apply {
|
||||
WatchSnapshotApply::Replaced | WatchSnapshotApply::PairingChanged => {
|
||||
WatchPersistenceAction::Replace
|
||||
@@ -434,19 +495,95 @@ impl WatchRuntime {
|
||||
code,
|
||||
period: entry.period,
|
||||
valid_until,
|
||||
remaining: valid_until.saturating_sub(unix_seconds),
|
||||
})
|
||||
})
|
||||
.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) {
|
||||
self.receiver.clear_secrets();
|
||||
self.protected_data_available = false;
|
||||
self.presentation_state = WatchPresentationState::Locked;
|
||||
}
|
||||
|
||||
pub fn no_persisted_snapshot(&mut self) {
|
||||
self.receiver.revoke();
|
||||
self.protected_data_available = true;
|
||||
self.presentation_state = WatchPresentationState::Empty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::fs;
|
||||
|
||||
use ironstorage::{
|
||||
mobile_watch::{
|
||||
MobileWatchSnapshotState, WatchPersistenceAction, WatchRuntime, WatchSnapshotApply,
|
||||
WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender,
|
||||
MobileWatchSnapshotState, WatchPersistenceAction, WatchPresentationState, WatchRuntime,
|
||||
WatchSnapshotApply, WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender,
|
||||
},
|
||||
otp::OtpAlgorithm,
|
||||
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();
|
||||
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())?;
|
||||
assert_eq!(update.apply(), WatchSnapshotApply::Replaced);
|
||||
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].valid_until(), 60);
|
||||
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();
|
||||
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())?;
|
||||
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.persistence(), WatchPersistenceAction::Delete);
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ use std::{
|
||||
};
|
||||
|
||||
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,
|
||||
WatchSnapshotUpdate as StorageSnapshotUpdate, WatchTotpRecord as StorageTotpRecord,
|
||||
};
|
||||
@@ -83,10 +84,11 @@ pub struct WatchTotpRecord {
|
||||
pub code: String,
|
||||
pub period: u64,
|
||||
pub valid_until: u64,
|
||||
pub remaining: u64,
|
||||
}
|
||||
|
||||
impl From<StorageTotpRecord> for WatchTotpRecord {
|
||||
fn from(value: StorageTotpRecord) -> Self {
|
||||
impl From<&StorageTotpRecord> for WatchTotpRecord {
|
||||
fn from(value: &StorageTotpRecord) -> Self {
|
||||
Self {
|
||||
path: value.path().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"),
|
||||
period: value.period(),
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
.lock()
|
||||
.map_err(|_| lock_error())?
|
||||
.records_at(unix_seconds)
|
||||
.map(|records| records.into_iter().map(Into::into).collect())
|
||||
.presentation_at(unix_seconds)
|
||||
.map(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> {
|
||||
self.runtime
|
||||
.lock()
|
||||
@@ -183,8 +259,16 @@ mod tests {
|
||||
fn bridge_masks_records_when_protected_data_is_unavailable() {
|
||||
let core = super::watch_core();
|
||||
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");
|
||||
assert!(core.records_at(59).is_err());
|
||||
assert_eq!(
|
||||
core.presentation_at(59).expect("locked presentation").state,
|
||||
super::WatchPresentationState::Locked
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user