Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Vault/Sources/VaultiOS/Views/VaultListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,21 @@ struct VaultListView<
var viewGenerator: Generator
var copyActionHandler: any VaultItemCopyActionHandler
var previewActionHandler: any VaultItemPreviewActionHandler
@Binding var pendingOpenItemDetail: Identifier<VaultItem>?
let openDetailSubject = PassthroughSubject<VaultItemEncryptionPayload, Never>()

init(
localSettings: LocalSettings,
viewGenerator: Generator,
copyActionHandler: any VaultItemCopyActionHandler,
previewActionHandler: any VaultItemPreviewActionHandler,
pendingOpenItemDetail: Binding<Identifier<VaultItem>?> = .constant(nil),
) {
self.localSettings = localSettings
self.viewGenerator = viewGenerator
self.copyActionHandler = copyActionHandler
self.previewActionHandler = previewActionHandler
_pendingOpenItemDetail = pendingOpenItemDetail
}

@Environment(VaultDataModel.self) private var dataModel
Expand Down Expand Up @@ -112,6 +115,13 @@ struct VaultListView<
}
.onAppear {
viewGenerator.didAppear()
openPendingItemDetailIfPossible()
}
.onChange(of: pendingOpenItemDetail) { _, _ in
openPendingItemDetailIfPossible()
}
.onChange(of: dataModel.items.map(\.id)) { _, _ in
openPendingItemDetailIfPossible()
}
}

Expand All @@ -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)
}
}
23 changes: 23 additions & 0 deletions Vault/Sources/VaultiOS/Views/VaultMainNavigationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct VaultMainNavigationView: View {
@State var deviceAuthenticationService: DeviceAuthenticationService
@State var vaultDataModel: VaultDataModel
@State var injector: VaultInjector
@Binding var pendingOpenItemDetail: Identifier<VaultItem>?
@Environment(\.presentToast) private var presentToast

@Environment(\.scenePhase) private var scenePhase
Expand All @@ -24,6 +25,22 @@ struct VaultMainNavigationView: View {
case demos
}

init(
pasteboard: Pasteboard,
localSettings: LocalSettings,
deviceAuthenticationService: DeviceAuthenticationService,
vaultDataModel: VaultDataModel,
injector: VaultInjector,
pendingOpenItemDetail: Binding<Identifier<VaultItem>?> = .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) {
Expand Down Expand Up @@ -70,6 +87,7 @@ struct VaultMainNavigationView: View {
viewGenerator: VaultRoot.genericVaultItemPreviewViewGenerator,
copyActionHandler: VaultRoot.vaultItemCopyHandler,
previewActionHandler: VaultRoot.vaultItemPreviewActionHandler,
pendingOpenItemDetail: $pendingOpenItemDetail,
)
.navigationBarTitleDisplayMode(.inline)
case .tags:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions Vault/Sources/VaultiOS/Views/VaultMainScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<VaultItem>?

public init() {
// Don't wire auto-backup and widget reloads when the store failed
Expand All @@ -33,6 +34,7 @@ public struct VaultMainScene: Scene {
deviceAuthenticationService: deviceAuthenticationService,
vaultDataModel: vaultDataModel,
injector: injector,
pendingOpenItemDetail: $pendingOpenItemDetail,
)
.installToast(position: .top)
.onOpenURL(perform: handle(url:))
Expand All @@ -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)
}
}
}
9 changes: 9 additions & 0 deletions Vault/Sources/VaultiOSShared/WidgetDeepLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -25,12 +30,16 @@ 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
}
}

public enum Action: Equatable, Sendable {
case incrementHOTP(itemID: UUID)
case openItemDetail(itemID: UUID)
}
}
74 changes: 74 additions & 0 deletions Vault/Sources/VaultiOSWidgets/Intent/OTPWidgetCodeActions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
48 changes: 43 additions & 5 deletions Vault/Sources/VaultiOSWidgets/Loader/WidgetVaultLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,22 @@ 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
/// built once.
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 }
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Vault/Sources/VaultiOSWidgets/OTPWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand Down
11 changes: 5 additions & 6 deletions Vault/Sources/VaultiOSWidgets/OTPWidgetEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -45,13 +45,15 @@ public enum OTPWidgetSnapshot: Sendable, Equatable {
public var periodEnd: Date

public init(
itemID: UUID,
issuer: String,
accountName: String,
code: String,
digits: Int,
periodStart: Date,
periodEnd: Date,
) {
self.itemID = itemID
self.issuer = issuer
self.accountName = accountName
self.code = code
Expand All @@ -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
}
}
Expand Down
Loading
Loading