From c8c713eaf66c8d5280681fd16e7507d4c0b2e611 Mon Sep 17 00:00:00 2001 From: Bradley Mackey Date: Thu, 28 May 2026 22:05:50 +0100 Subject: [PATCH 1/2] Update OTP widget tap behavior --- .../VaultiOS/Views/VaultListView.swift | 19 +++++ .../Views/VaultMainNavigationView.swift | 23 ++++++ .../VaultiOS/Views/VaultMainScene.swift | 4 + .../VaultiOSShared/WidgetDeepLink.swift | 9 +++ .../Intent/OTPWidgetCodeActions.swift | 74 +++++++++++++++++++ .../Loader/WidgetVaultLoader.swift | 48 ++++++++++-- Vault/Sources/VaultiOSWidgets/OTPWidget.swift | 2 +- .../VaultiOSWidgets/OTPWidgetEntry.swift | 11 ++- .../VaultiOSWidgets/OTPWidgetProvider.swift | 15 ++-- .../Views/OTPWidgetSmallView.swift | 49 +++++++++--- .../WidgetDeepLinkTests.swift | 14 ++++ 11 files changed, 238 insertions(+), 30 deletions(-) create mode 100644 Vault/Sources/VaultiOSWidgets/Intent/OTPWidgetCodeActions.swift diff --git a/Vault/Sources/VaultiOS/Views/VaultListView.swift b/Vault/Sources/VaultiOS/Views/VaultListView.swift index 052a41f94..19d58e302 100644 --- a/Vault/Sources/VaultiOS/Views/VaultListView.swift +++ b/Vault/Sources/VaultiOS/Views/VaultListView.swift @@ -11,6 +11,7 @@ struct VaultListView< var viewGenerator: Generator var copyActionHandler: any VaultItemCopyActionHandler var previewActionHandler: any VaultItemPreviewActionHandler + @Binding var pendingOpenItemDetail: Identifier? let openDetailSubject = PassthroughSubject() init( @@ -18,11 +19,13 @@ struct VaultListView< viewGenerator: Generator, copyActionHandler: any VaultItemCopyActionHandler, previewActionHandler: any VaultItemPreviewActionHandler, + pendingOpenItemDetail: Binding?> = .constant(nil), ) { self.localSettings = localSettings self.viewGenerator = viewGenerator self.copyActionHandler = copyActionHandler self.previewActionHandler = previewActionHandler + _pendingOpenItemDetail = pendingOpenItemDetail } @Environment(VaultDataModel.self) private var dataModel @@ -112,6 +115,13 @@ struct VaultListView< } .onAppear { viewGenerator.didAppear() + openPendingItemDetailIfPossible() + } + .onChange(of: pendingOpenItemDetail) { _, _ in + openPendingItemDetailIfPossible() + } + .onChange(of: dataModel.items.map(\.id)) { _, _ in + openPendingItemDetailIfPossible() } } @@ -138,4 +148,13 @@ struct VaultListView< } } } + + private func openPendingItemDetailIfPossible() { + guard let id = pendingOpenItemDetail, + let item = dataModel.code(id: id) + else { return } + + pendingOpenItemDetail = nil + modal = .detail(id, item, nil) + } } diff --git a/Vault/Sources/VaultiOS/Views/VaultMainNavigationView.swift b/Vault/Sources/VaultiOS/Views/VaultMainNavigationView.swift index aa7623095..43244bd70 100644 --- a/Vault/Sources/VaultiOS/Views/VaultMainNavigationView.swift +++ b/Vault/Sources/VaultiOS/Views/VaultMainNavigationView.swift @@ -9,6 +9,7 @@ struct VaultMainNavigationView: View { @State var deviceAuthenticationService: DeviceAuthenticationService @State var vaultDataModel: VaultDataModel @State var injector: VaultInjector + @Binding var pendingOpenItemDetail: Identifier? @Environment(\.presentToast) private var presentToast @Environment(\.scenePhase) private var scenePhase @@ -24,6 +25,22 @@ struct VaultMainNavigationView: View { case demos } + init( + pasteboard: Pasteboard, + localSettings: LocalSettings, + deviceAuthenticationService: DeviceAuthenticationService, + vaultDataModel: VaultDataModel, + injector: VaultInjector, + pendingOpenItemDetail: Binding?> = .constant(nil), + ) { + _pasteboard = State(initialValue: pasteboard) + _localSettings = State(initialValue: localSettings) + _deviceAuthenticationService = State(initialValue: deviceAuthenticationService) + _vaultDataModel = State(initialValue: vaultDataModel) + _injector = State(initialValue: injector) + _pendingOpenItemDetail = pendingOpenItemDetail + } + var body: some View { NavigationSplitView { List(selection: $selectedView) { @@ -70,6 +87,7 @@ struct VaultMainNavigationView: View { viewGenerator: VaultRoot.genericVaultItemPreviewViewGenerator, copyActionHandler: VaultRoot.vaultItemCopyHandler, previewActionHandler: VaultRoot.vaultItemPreviewActionHandler, + pendingOpenItemDetail: $pendingOpenItemDetail, ) .navigationBarTitleDisplayMode(.inline) case .tags: @@ -116,6 +134,11 @@ struct VaultMainNavigationView: View { .environment(deviceAuthenticationService) .environment(vaultDataModel) .environment(injector) + .onChange(of: pendingOpenItemDetail) { _, newValue in + if newValue != nil { + selectedView = .items + } + } .onChange(of: scenePhase) { _, newValue in switch newValue { case .background: diff --git a/Vault/Sources/VaultiOS/Views/VaultMainScene.swift b/Vault/Sources/VaultiOS/Views/VaultMainScene.swift index aa0646629..930097556 100644 --- a/Vault/Sources/VaultiOS/Views/VaultMainScene.swift +++ b/Vault/Sources/VaultiOS/Views/VaultMainScene.swift @@ -12,6 +12,7 @@ public struct VaultMainScene: Scene { @State private var deviceAuthenticationService = VaultRoot.deviceAuthenticationService @State private var vaultDataModel: VaultDataModel = VaultRoot.vaultDataModel @State private var injector: VaultInjector = VaultRoot.vaultInjector + @State private var pendingOpenItemDetail: Identifier? public init() { // Don't wire auto-backup and widget reloads when the store failed @@ -33,6 +34,7 @@ public struct VaultMainScene: Scene { deviceAuthenticationService: deviceAuthenticationService, vaultDataModel: vaultDataModel, injector: injector, + pendingOpenItemDetail: $pendingOpenItemDetail, ) .installToast(position: .top) .onOpenURL(perform: handle(url:)) @@ -47,6 +49,8 @@ public struct VaultMainScene: Scene { Task { try? await vaultDataModel.incrementCounter(id: .init(id: itemID)) } + case let .openItemDetail(itemID): + pendingOpenItemDetail = .init(id: itemID) } } } diff --git a/Vault/Sources/VaultiOSShared/WidgetDeepLink.swift b/Vault/Sources/VaultiOSShared/WidgetDeepLink.swift index 3dadac167..927243b92 100644 --- a/Vault/Sources/VaultiOSShared/WidgetDeepLink.swift +++ b/Vault/Sources/VaultiOSShared/WidgetDeepLink.swift @@ -16,6 +16,11 @@ public enum WidgetDeepLink { URL(string: "\(scheme)://otp/\(itemID.uuidString)/increment").unsafelyUnwrapped } + /// Opens the main app directly to an OTP item's detail screen. + public static func openItemDetail(itemID: UUID) -> URL { + URL(string: "\(scheme)://otp/\(itemID.uuidString)/detail").unsafelyUnwrapped + } + /// Parses a URL produced by one of the constructors above. Returns nil /// if the URL does not match a known shape. public static func parse(_ url: URL) -> Action? { @@ -25,6 +30,9 @@ public enum WidgetDeepLink { case let ("otp", components) where components.count == 2 && components[1] == "increment": guard let id = UUID(uuidString: components[0]) else { return nil } return .incrementHOTP(itemID: id) + case let ("otp", components) where components.count == 2 && components[1] == "detail": + guard let id = UUID(uuidString: components[0]) else { return nil } + return .openItemDetail(itemID: id) default: return nil } @@ -32,5 +40,6 @@ public enum WidgetDeepLink { public enum Action: Equatable, Sendable { case incrementHOTP(itemID: UUID) + case openItemDetail(itemID: UUID) } } diff --git a/Vault/Sources/VaultiOSWidgets/Intent/OTPWidgetCodeActions.swift b/Vault/Sources/VaultiOSWidgets/Intent/OTPWidgetCodeActions.swift new file mode 100644 index 000000000..73e8d3fe3 --- /dev/null +++ b/Vault/Sources/VaultiOSWidgets/Intent/OTPWidgetCodeActions.swift @@ -0,0 +1,74 @@ +import AppIntents +import Foundation +import WidgetKit +#if canImport(UIKit) +import UIKit +#endif + +public struct CopyTOTPCodeIntent: AppIntent { + public nonisolated static let title: LocalizedStringResource = "Copy Code" + public nonisolated static let openAppWhenRun = false + + @Parameter(title: "Item ID") + public var itemID: String + + public init() { + itemID = "" + } + + public init(itemID: UUID) { + self.itemID = itemID.uuidString + } + + public func perform() async throws -> some IntentResult { + guard let id = UUID(uuidString: itemID), + let code = try await WidgetVaultLoader.shared.currentTOTPCode(id: id) + else { + return .result() + } + + WidgetPasteboard.copyOTP(code) + return .result() + } +} + +public struct IncrementAndCopyHOTPCodeIntent: AppIntent { + public nonisolated static let title: LocalizedStringResource = "Next Code" + public nonisolated static let openAppWhenRun = false + + @Parameter(title: "Item ID") + public var itemID: String + + public init() { + itemID = "" + } + + public init(itemID: UUID) { + self.itemID = itemID.uuidString + } + + public func perform() async throws -> some IntentResult { + guard let id = UUID(uuidString: itemID), + let code = try await WidgetVaultLoader.shared.incrementAndRenderHOTPCode(id: id) + else { + return .result() + } + + WidgetPasteboard.copyOTP(code) + WidgetCenter.shared.reloadTimelines(ofKind: OTPWidget.kind) + return .result() + } +} + +enum WidgetPasteboard { + private static let concealedTypeIdentifier = "org.nspasteboard.ConcealedType" + + static func copyOTP(_ string: String) { + #if canImport(UIKit) + UIPasteboard.general.setItems([[ + UIPasteboard.typeAutomatic: string, + concealedTypeIdentifier: string, + ]], options: [.localOnly: true]) + #endif + } +} diff --git a/Vault/Sources/VaultiOSWidgets/Loader/WidgetVaultLoader.swift b/Vault/Sources/VaultiOSWidgets/Loader/WidgetVaultLoader.swift index 4a7642493..ddf994d90 100644 --- a/Vault/Sources/VaultiOSWidgets/Loader/WidgetVaultLoader.swift +++ b/Vault/Sources/VaultiOSWidgets/Loader/WidgetVaultLoader.swift @@ -10,7 +10,12 @@ public import VaultFeed /// `VaultRoot.vaultStore`), so this type opens its own `PersistedLocalVaultStore` /// pointed at the same App Group container. public actor WidgetVaultLoader { - public typealias StoreFactory = @Sendable () throws -> any VaultStoreReader + /// The capabilities the widget process needs: reads, plus advancing an + /// HOTP counter. Deliberately narrower than `VaultStore` — the extension + /// can never insert, update, delete, reorder, or export. + public typealias WidgetStore = VaultStoreHOTPIncrementer & VaultStoreReader + + public typealias StoreFactory = @Sendable () throws -> any WidgetStore /// Process-wide default instance. Widget timeline providers should reuse /// the same loader across calls so the underlying `ModelContainer` is @@ -18,9 +23,9 @@ public actor WidgetVaultLoader { public static let shared = WidgetVaultLoader() private let makeStore: StoreFactory - private var store: (any VaultStoreReader)? + private var store: (any WidgetStore)? - public init(store: (any VaultStoreReader)? = nil) { + public init(store: (any WidgetStore)? = nil) { if let store { self.store = store makeStore = { store } @@ -54,7 +59,40 @@ public actor WidgetVaultLoader { return VaultItemWidgetEligibility.isEligible(item) ? item : nil } - private static func makeSharedStore() throws -> any VaultStoreReader { + /// Renders the current TOTP value for an eligible item. + public func currentTOTPCode(id: UUID, date: Date = Date()) async throws -> String? { + guard let item = try await eligibleItem(id: id), + case let .otpCode(otp) = item.item, + case let .totp(period) = otp.type + else { + return nil + } + + let code = TOTPAuthCode(period: period, data: otp.data) + return try code.renderCode(epochSeconds: UInt64(date.timeIntervalSince1970)) + } + + /// Advances an eligible HOTP item and returns the freshly generated code. + /// + /// This is the only write the widget process performs. The store is still + /// opened `.openOnly` so a transient open failure can never trigger the + /// recovery path from an extension (see #526). + public func incrementAndRenderHOTPCode(id: UUID) async throws -> String? { + guard let item = try await eligibleItem(id: id), + case let .otpCode(otp) = item.item, + case let .hotp(counter) = otp.type + else { + return nil + } + + let currentStore = try store ?? openStore() + let nextCounter = counter + 1 + let code = try HOTPAuthCode(counter: nextCounter, data: otp.data).renderCode() + try await currentStore.incrementCounter(id: item.id) + return code + } + + private static func makeSharedStore() throws -> any WidgetStore { try PersistedLocalVaultStoreFactory( storageDirectory: VaultSharedStorage.directory(), recoveryMode: .openOnly, @@ -71,7 +109,7 @@ public actor WidgetVaultLoader { } } - private func openStore() throws -> any VaultStoreReader { + private func openStore() throws -> any WidgetStore { let openedStore = try makeStore() store = openedStore return openedStore diff --git a/Vault/Sources/VaultiOSWidgets/OTPWidget.swift b/Vault/Sources/VaultiOSWidgets/OTPWidget.swift index 322142cb9..cdb37e0b9 100644 --- a/Vault/Sources/VaultiOSWidgets/OTPWidget.swift +++ b/Vault/Sources/VaultiOSWidgets/OTPWidget.swift @@ -12,7 +12,7 @@ import WidgetKit /// ``` public struct OTPWidget: Widget { /// Stable kind identifier used by `WidgetCenter` to reload timelines. - public static let kind = "com.badbundle.vault.OTPWidget" + public nonisolated static let kind = "com.badbundle.vault.OTPWidget" public init() {} diff --git a/Vault/Sources/VaultiOSWidgets/OTPWidgetEntry.swift b/Vault/Sources/VaultiOSWidgets/OTPWidgetEntry.swift index 7ff3877d4..2d887ecf0 100644 --- a/Vault/Sources/VaultiOSWidgets/OTPWidgetEntry.swift +++ b/Vault/Sources/VaultiOSWidgets/OTPWidgetEntry.swift @@ -31,12 +31,12 @@ public enum OTPWidgetSnapshot: Sendable, Equatable { /// `[periodStart, periodEnd]` interval directly. case totp(TOTP) - /// HOTP code captured at the time the entry was built. The widget never - /// auto-increments — the user must tap the widget to open the app and - /// advance the counter. + /// HOTP item metadata. The code is intentionally not stored in the + /// snapshot because the persisted counter may already be stale. case hotp(HOTP) public struct TOTP: Sendable, Equatable { + public var itemID: UUID public var issuer: String public var accountName: String public var code: String @@ -45,6 +45,7 @@ public enum OTPWidgetSnapshot: Sendable, Equatable { public var periodEnd: Date public init( + itemID: UUID, issuer: String, accountName: String, code: String, @@ -52,6 +53,7 @@ public enum OTPWidgetSnapshot: Sendable, Equatable { periodStart: Date, periodEnd: Date, ) { + self.itemID = itemID self.issuer = issuer self.accountName = accountName self.code = code @@ -65,20 +67,17 @@ public enum OTPWidgetSnapshot: Sendable, Equatable { public var itemID: UUID public var issuer: String public var accountName: String - public var code: String public var digits: Int public init( itemID: UUID, issuer: String, accountName: String, - code: String, digits: Int, ) { self.itemID = itemID self.issuer = issuer self.accountName = accountName - self.code = code self.digits = digits } } diff --git a/Vault/Sources/VaultiOSWidgets/OTPWidgetProvider.swift b/Vault/Sources/VaultiOSWidgets/OTPWidgetProvider.swift index d231004ad..9e974ffbc 100644 --- a/Vault/Sources/VaultiOSWidgets/OTPWidgetProvider.swift +++ b/Vault/Sources/VaultiOSWidgets/OTPWidgetProvider.swift @@ -61,7 +61,7 @@ public struct OTPWidgetProvider: AppIntentTimelineProvider { switch otp.type { case let .totp(period): - return totpTimeline(otp: otp, period: period) + return totpTimeline(itemID: item.id.rawValue, otp: otp, period: period) case let .hotp(counter): return hotpTimeline(itemID: item.id.rawValue, otp: otp, counter: counter) } @@ -70,6 +70,7 @@ public struct OTPWidgetProvider: AppIntentTimelineProvider { // MARK: - TOTP private func totpTimeline( + itemID: UUID, otp: OTPAuthCode, period: UInt64, ) -> Timeline { @@ -79,9 +80,10 @@ public struct OTPWidgetProvider: AppIntentTimelineProvider { let nextState = currentState.offset(time: Double(period)) let entries: [OTPWidgetEntry] = [ - makeTOTPEntry(at: now, otp: otp, period: period, state: currentState), + makeTOTPEntry(at: now, itemID: itemID, otp: otp, period: period, state: currentState), makeTOTPEntry( at: Date(timeIntervalSince1970: currentState.endTime), + itemID: itemID, otp: otp, period: period, state: nextState, @@ -99,6 +101,7 @@ public struct OTPWidgetProvider: AppIntentTimelineProvider { private func makeTOTPEntry( at date: Date, + itemID: UUID, otp: OTPAuthCode, period: UInt64, state: OTPCodeTimerState, @@ -111,6 +114,7 @@ public struct OTPWidgetProvider: AppIntentTimelineProvider { return OTPWidgetEntry( date: date, snapshot: .totp(.init( + itemID: itemID, issuer: otp.data.issuer, accountName: otp.data.accountName, code: rendered, @@ -126,19 +130,14 @@ public struct OTPWidgetProvider: AppIntentTimelineProvider { private func hotpTimeline( itemID: UUID, otp: OTPAuthCode, - counter: UInt64, + counter _: UInt64, ) -> Timeline { - let hotp = HOTPAuthCode(counter: counter, data: otp.data) - guard let rendered = try? hotp.renderCode() else { - return unavailableTimeline() - } let entry = OTPWidgetEntry( date: Date(), snapshot: .hotp(.init( itemID: itemID, issuer: otp.data.issuer, accountName: otp.data.accountName, - code: rendered, digits: Int(otp.data.digits.value), )), ) diff --git a/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetSmallView.swift b/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetSmallView.swift index ae928b87e..314e31ee1 100644 --- a/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetSmallView.swift +++ b/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetSmallView.swift @@ -1,3 +1,4 @@ +import AppIntents import SwiftUI import VaultFeed import VaultiOSShared @@ -6,6 +7,10 @@ import WidgetKit /// `systemSmall` layout. Mirrors `TOTPCodePreviewView` from the in-app /// preview tile — icon top-left, issuer/account stack, large monospaced /// chunked digits, horizontal progress bar at the bottom. +/// +/// This is the only family with in-widget actions. The lock-screen accessory +/// families stay non-interactive on purpose: their buttons would be reachable +/// on a locked device, and advancing an HOTP counter is irreversible. struct OTPWidgetSmallView: View { let snapshot: OTPWidgetSnapshot @@ -26,7 +31,6 @@ struct OTPWidgetSmallView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .widgetURL(deepLinkURL) } // MARK: - Pieces @@ -37,8 +41,11 @@ struct OTPWidgetSmallView: View { .foregroundStyle(.secondary) } + /// Tapping the labels opens the item in the app. The code itself carries + /// the action, so this stays the route to the full detail screen. + @ViewBuilder private var labelsStack: some View { - VStack(alignment: .leading, spacing: 2) { + let content = VStack(alignment: .leading, spacing: 2) { Text(displayIssuer) .font(.title3.bold()) .minimumScaleFactor(0.7) @@ -52,16 +59,45 @@ struct OTPWidgetSmallView: View { .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) + + switch snapshot { + case let .totp(state): + Link(destination: WidgetDeepLink.openItemDetail(itemID: state.itemID)) { + content + } + case let .hotp(state): + Link(destination: WidgetDeepLink.openItemDetail(itemID: state.itemID)) { + content + } + case .unavailable, .placeholder: + content + } } + @ViewBuilder private var codeSection: some View { - OTPCodeTextView(codeState: codeState) + let content = OTPCodeTextView(codeState: codeState) .font(.system(.largeTitle, design: .monospaced)) .fontWeight(.heavy) .minimumScaleFactor(0.5) .lineLimit(1) .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) + + switch snapshot { + case let .totp(state): + Button(intent: CopyTOTPCodeIntent(itemID: state.itemID)) { + content + } + .buttonStyle(.plain) + case let .hotp(state): + Button(intent: IncrementAndCopyHOTPCodeIntent(itemID: state.itemID)) { + content + } + .buttonStyle(.plain) + case .unavailable, .placeholder: + content + } } @ViewBuilder @@ -108,11 +144,4 @@ struct OTPWidgetSmallView: View { case .placeholder: "" } } - - private var deepLinkURL: URL? { - switch snapshot { - case let .hotp(state): WidgetDeepLink.hotpIncrement(itemID: state.itemID) - case .totp, .unavailable, .placeholder: nil - } - } } diff --git a/Vault/Tests/VaultiOSWidgetsTests/WidgetDeepLinkTests.swift b/Vault/Tests/VaultiOSWidgetsTests/WidgetDeepLinkTests.swift index c500e8c95..eeaccea7b 100644 --- a/Vault/Tests/VaultiOSWidgetsTests/WidgetDeepLinkTests.swift +++ b/Vault/Tests/VaultiOSWidgetsTests/WidgetDeepLinkTests.swift @@ -11,6 +11,14 @@ struct WidgetDeepLinkTests { #expect(action == .incrementHOTP(itemID: id)) } + @Test + func openItemDetail_roundTripsViaParse() { + let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC").unsafelyUnwrapped + let url = WidgetDeepLink.openItemDetail(itemID: id) + let action = WidgetDeepLink.parse(url) + #expect(action == .openItemDetail(itemID: id)) + } + @Test func parse_returnsNil_forUnknownScheme() { let url = URL(string: "https://example.com/otp/abc/increment").unsafelyUnwrapped @@ -40,4 +48,10 @@ struct WidgetDeepLinkTests { let url = URL(string: "vault://otp/not-a-uuid/increment").unsafelyUnwrapped #expect(WidgetDeepLink.parse(url) == nil) } + + @Test + func parse_returnsNil_forMalformedDetailUUID() { + let url = URL(string: "vault://otp/not-a-uuid/detail").unsafelyUnwrapped + #expect(WidgetDeepLink.parse(url) == nil) + } } From b71d9085871277d6acaa5e53dc13780d0b94877e Mon Sep 17 00:00:00 2001 From: Bradley Mackey Date: Fri, 18 Sep 2026 10:54:19 +0400 Subject: [PATCH 2/2] fix(widgets): scope in-widget code actions to the home screen Bring the rebased tap behaviour in line with the architecture that landed while this branch sat unmerged. - Grant the widget process read plus HOTP increment only, via a WidgetStore = VaultStoreReader & VaultStoreHOTPIncrementer alias. It still cannot insert, update, delete, reorder, or export, and the store is opened .openOnly so an extension can never hit the recovery path (#526). - Keep the lock-screen accessory families non-interactive. Their buttons would be reachable on a locked device and advancing an HOTP counter cannot be undone, so they stay plain deep links. - Follow the branch in dropping the stored HOTP code: the persisted counter may be stale, so every family masks the digits until the user advances it. - Cover the intent-facing loader paths, including that an ineligible item yields no code and never advances the counter. Co-Authored-By: Claude Fable 5 --- .../OTPWidgetAccessoryRectangularView.swift | 4 +- .../Views/OTPWidgetSmallView.swift | 4 +- .../OTPWidgetLoadingTests.swift | 11 +- .../WidgetVaultLoaderCodeActionTests.swift | 199 ++++++++++++++++++ 4 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 Vault/Tests/VaultiOSWidgetsTests/WidgetVaultLoaderCodeActionTests.swift diff --git a/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetAccessoryRectangularView.swift b/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetAccessoryRectangularView.swift index 223015720..12cbe1bf1 100644 --- a/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetAccessoryRectangularView.swift +++ b/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetAccessoryRectangularView.swift @@ -46,7 +46,9 @@ struct OTPWidgetAccessoryRectangularView: View { private var codeState: OTPCodeState { switch snapshot { case let .totp(state): .visible(state.code) - case let .hotp(state): .visible(state.code) + // The stored counter may already be stale, so the widget shows + // masked digits until the user advances it in the app. + case let .hotp(state): .locked(code: String(repeating: "0", count: state.digits)) case .unavailable, .placeholder: .notReady } } diff --git a/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetSmallView.swift b/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetSmallView.swift index 314e31ee1..8de539704 100644 --- a/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetSmallView.swift +++ b/Vault/Sources/VaultiOSWidgets/Views/OTPWidgetSmallView.swift @@ -122,7 +122,9 @@ struct OTPWidgetSmallView: View { private var codeState: OTPCodeState { switch snapshot { case let .totp(state): .visible(state.code) - case let .hotp(state): .visible(state.code) + // The persisted counter may already be stale, so the widget masks the + // digits until the user taps to advance it. + case let .hotp(state): .locked(code: String(repeating: "0", count: state.digits)) case .unavailable, .placeholder: .notReady } } diff --git a/Vault/Tests/VaultiOSWidgetsTests/OTPWidgetLoadingTests.swift b/Vault/Tests/VaultiOSWidgetsTests/OTPWidgetLoadingTests.swift index 58486acb1..fad5aea59 100644 --- a/Vault/Tests/VaultiOSWidgetsTests/OTPWidgetLoadingTests.swift +++ b/Vault/Tests/VaultiOSWidgetsTests/OTPWidgetLoadingTests.swift @@ -137,7 +137,7 @@ private enum WidgetTestError: Error, Equatable { private final class StoreFactoryScript: @unchecked Sendable { enum Result { case failure(WidgetTestError) - case success(any VaultStoreReader) + case success(any WidgetVaultLoader.WidgetStore) } private var results: [Result] @@ -147,7 +147,7 @@ private final class StoreFactoryScript: @unchecked Sendable { self.results = results } - func makeStore() throws -> any VaultStoreReader { + func makeStore() throws -> any WidgetVaultLoader.WidgetStore { openCallCount += 1 let result = results.isEmpty ? .failure(.open) : results.removeFirst() switch result { @@ -159,9 +159,10 @@ private final class StoreFactoryScript: @unchecked Sendable { } } -private actor FakeVaultStoreReader: VaultStoreReader { +private actor FakeVaultStoreReader: VaultStoreHOTPIncrementer, VaultStoreReader { private var results: [Result, WidgetTestError>] private(set) var retrieveCallCount = 0 + private(set) var incrementedIDs = [Identifier]() init(results: [Result, WidgetTestError>]) { self.results = results @@ -181,6 +182,10 @@ private actor FakeVaultStoreReader: VaultStoreReader { } } + func incrementCounter(id: Identifier) async throws { + incrementedIDs.append(id) + } + var hasAnyItems: Bool { get async throws { true } } diff --git a/Vault/Tests/VaultiOSWidgetsTests/WidgetVaultLoaderCodeActionTests.swift b/Vault/Tests/VaultiOSWidgetsTests/WidgetVaultLoaderCodeActionTests.swift new file mode 100644 index 000000000..f3d14e13e --- /dev/null +++ b/Vault/Tests/VaultiOSWidgetsTests/WidgetVaultLoaderCodeActionTests.swift @@ -0,0 +1,199 @@ +import Foundation +import Testing +import VaultCore +import VaultFeed +@testable import VaultiOSWidgets + +/// Covers the two code-producing paths the widget's `AppIntent`s call. +/// +/// The eligibility gate matters most here: an intent fires from a widget the +/// user tapped, outside any auth prompt, so an ineligible item must yield no +/// code and — for HOTP — must not advance the counter. +struct WidgetVaultLoaderCodeActionTests { + // MARK: - HOTP + + @Test + func incrementAndRenderHOTPCode_advancesCounterAndReturnsCode() async throws { + let item = makeHOTPVaultItem(counter: 4) + let store = IncrementingFakeStore(items: [item]) + let loader = WidgetVaultLoader(store: store) + + let code = try await loader.incrementAndRenderHOTPCode(id: item.id.rawValue) + + #expect(code != nil) + #expect(await store.incrementedIDs == [item.id]) + } + + @Test + func incrementAndRenderHOTPCode_rendersTheNextCounterNotTheCurrentOne() async throws { + let item = makeHOTPVaultItem(counter: 4) + let loader = WidgetVaultLoader(store: IncrementingFakeStore(items: [item])) + + let code = try await loader.incrementAndRenderHOTPCode(id: item.id.rawValue) + + let expected = try HOTPAuthCode(counter: UInt64(5), data: hotpData()).renderCode() + #expect(code == expected) + } + + @Test + func incrementAndRenderHOTPCode_ineligibleItemDoesNotIncrement() async throws { + let item = makeHOTPVaultItem(counter: 1, lockState: .lockedWithNativeSecurity) + let store = IncrementingFakeStore(items: [item]) + let loader = WidgetVaultLoader(store: store) + + let code = try await loader.incrementAndRenderHOTPCode(id: item.id.rawValue) + + #expect(code == nil) + #expect(await store.incrementedIDs.isEmpty) + } + + @Test + func incrementAndRenderHOTPCode_killphraseItemDoesNotIncrement() async throws { + let item = makeHOTPVaultItem(counter: 1, killphrase: .init(salt: Data([1]), digest: Data([2]))) + let store = IncrementingFakeStore(items: [item]) + let loader = WidgetVaultLoader(store: store) + + let code = try await loader.incrementAndRenderHOTPCode(id: item.id.rawValue) + + #expect(code == nil) + #expect(await store.incrementedIDs.isEmpty) + } + + @Test + func incrementAndRenderHOTPCode_totpItemReturnsNil() async throws { + let item = makeTOTPVaultItem() + let store = IncrementingFakeStore(items: [item]) + let loader = WidgetVaultLoader(store: store) + + let code = try await loader.incrementAndRenderHOTPCode(id: item.id.rawValue) + + #expect(code == nil) + #expect(await store.incrementedIDs.isEmpty) + } + + @Test + func incrementAndRenderHOTPCode_unknownItemReturnsNil() async throws { + let store = IncrementingFakeStore(items: []) + let loader = WidgetVaultLoader(store: store) + + let code = try await loader.incrementAndRenderHOTPCode(id: UUID()) + + #expect(code == nil) + #expect(await store.incrementedIDs.isEmpty) + } + + // MARK: - TOTP + + @Test + func currentTOTPCode_returnsCodeForEligibleItem() async throws { + let item = makeTOTPVaultItem() + let loader = WidgetVaultLoader(store: IncrementingFakeStore(items: [item])) + + let code = try await loader.currentTOTPCode(id: item.id.rawValue, date: Date(timeIntervalSince1970: 0)) + + #expect(code != nil) + } + + @Test + func currentTOTPCode_ineligibleItemReturnsNil() async throws { + let item = makeTOTPVaultItem(visibility: .onlySearch) + let loader = WidgetVaultLoader(store: IncrementingFakeStore(items: [item])) + + let code = try await loader.currentTOTPCode(id: item.id.rawValue) + + #expect(code == nil) + } + + @Test + func currentTOTPCode_hotpItemReturnsNil() async throws { + let item = makeHOTPVaultItem(counter: 1) + let loader = WidgetVaultLoader(store: IncrementingFakeStore(items: [item])) + + let code = try await loader.currentTOTPCode(id: item.id.rawValue) + + #expect(code == nil) + } +} + +// MARK: - Helpers + +private actor IncrementingFakeStore: VaultStoreHOTPIncrementer, VaultStoreReader { + private let items: [VaultItem] + private(set) var incrementedIDs = [Identifier]() + + init(items: [VaultItem]) { + self.items = items + } + + func retrieve( + query _: VaultStoreQuery, + searchPassphraseMatcher _: (any SearchPassphraseMatcher)?, + ) async throws -> VaultRetrievalResult { + .init(items: items) + } + + func incrementCounter(id: Identifier) async throws { + incrementedIDs.append(id) + } + + var hasAnyItems: Bool { + get async throws { items.isNotEmpty } + } +} + +private func hotpData() -> OTPAuthCodeData { + .init( + secret: .init(data: Data(repeating: 1, count: 20), format: .base32), + accountName: "account", + issuer: "Issuer", + ) +} + +private func makeHOTPVaultItem( + counter: UInt64, + killphrase: KillphraseDigest? = nil, + lockState: VaultItemLockState = .notLocked, +) -> VaultItem { + makeItem( + payload: .otpCode(.init(type: .hotp(counter: counter), data: hotpData())), + visibility: .always, + killphrase: killphrase, + lockState: lockState, + ) +} + +private func makeTOTPVaultItem(visibility: VaultItemVisibility = .always) -> VaultItem { + makeItem( + payload: .otpCode(.init(type: .totp(), data: hotpData())), + visibility: visibility, + killphrase: nil, + lockState: .notLocked, + ) +} + +private func makeItem( + payload: VaultItem.Payload, + visibility: VaultItemVisibility, + killphrase: KillphraseDigest?, + lockState: VaultItemLockState, +) -> VaultItem { + VaultItem( + metadata: .init( + id: .new(), + created: Date(timeIntervalSince1970: 0), + updated: Date(timeIntervalSince1970: 0), + relativeOrder: .min, + userDescription: "any", + tags: [], + visibility: visibility, + searchableLevel: .full, + searchPassphrase: nil, + killphrase: killphrase, + lockState: lockState, + color: nil, + showInQuickType: true, + previewMode: .titleAndFirstLine, + ), + item: payload, + ) +}