Skip to content
Draft
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
111 changes: 111 additions & 0 deletions Bitkit/Components/RemoveHwWalletDialog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import SwiftUI

/// Confirms removing a paired hardware wallet, offering to carry its name and tags in the backup so
/// re-pairing the device restores them. Shared by the wallet screen and the hardware wallet settings.
///
/// A card rather than a native `.alert`, which cannot hold the switch. Ports bitkit-android's
/// `RemoveHwWalletDialog`.
struct RemoveHwWalletDialog: View {
let walletName: String
@Binding var keepBackupData: Bool
let onConfirm: () -> Void
let onDismiss: () -> Void

var body: some View {
ZStack {
Color.black.opacity(0.6)
.ignoresSafeArea()
.onTapGesture(perform: onDismiss)
.accessibilityHidden(true)

VStack(alignment: .leading, spacing: 0) {
SubtitleText(t("hardware__remove_dialog_title", variables: ["name": walletName]))
.padding(.bottom, 8)

BodyMText(t("hardware__remove_dialog_text"))
.padding(.bottom, 16)

keepBackupDataRow
.padding(.bottom, 24)

HStack(spacing: 16) {
CustomButton(title: t("common__dialog_cancel"), variant: .secondary, shouldExpand: true) {
onDismiss()
}
.accessibilityIdentifier("DialogCancel")

CustomButton(title: t("common__remove"), shouldExpand: true) {
onConfirm()
}
.accessibilityIdentifier("DialogConfirm")
}
}
.padding(24)
.background(Color.gray6)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal, 32)
.frame(maxWidth: 400)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("RemoveHwWalletDialog")
}

private var keepBackupDataRow: some View {
HStack(spacing: 16) {
BodyMSBText(t("hardware__remove_dialog_keep"))
.frame(maxWidth: .infinity, alignment: .leading)

Toggle("", isOn: $keepBackupData)
.toggleStyle(SwitchToggleStyle(tint: .brandAccent))
.labelsHidden()
.accessibilityLabel(t("hardware__remove_dialog_keep"))
.accessibilityIdentifier("HwRemoveKeepBackupToggle")
}
.contentShape(Rectangle())
.onTapGesture { keepBackupData.toggle() }
}
}

extension RemoveHwWalletDialog {
/// Message for a failed removal. An unreadable tag read leaves the wallet untouched, so it names
/// the way through instead of asking for a retry that would repeat the same failure.
static func errorDescription(for error: Error) -> String {
if let removalError = error as? HwWalletRemovalError, removalError == .backupDataUnreadable {
return t("hardware__remove_keep_error")
}
return t("hardware__remove_error")
}
}

extension View {
/// Overlays `RemoveHwWalletDialog` while `walletName` is non-nil. Nil dismisses it, so the caller
/// keeps the wallet being removed in one piece of state rather than two that can disagree.
func removeHwWalletDialog(
walletName: String?,
keepBackupData: Binding<Bool>,
onConfirm: @escaping () -> Void,
onDismiss: @escaping () -> Void
) -> some View {
overlay {
if let walletName {
RemoveHwWalletDialog(
walletName: walletName,
keepBackupData: keepBackupData,
onConfirm: onConfirm,
onDismiss: onDismiss
)
}
}
}
}

#Preview {
Color.black
.removeHwWalletDialog(
walletName: "Trezor Safe 3",
keepBackupData: .constant(true),
onConfirm: {},
onDismiss: {}
)
.preferredColorScheme(.dark)
}
6 changes: 5 additions & 1 deletion Bitkit/Managers/HwDeviceSessioning.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ protocol HwDeviceSessioning: AnyObject, Sendable {
func warmUpConnection(deviceId: String)
/// Forgets every stored entry of `walletId`, keeping transport credentials while another
/// identity of the same device remains paired.
func forgetWallet(walletId: String) async
///
/// - Parameter pendingName: a name to keep for the wallet being forgotten, so re-pairing the
/// device restores it, or nil to drop any name kept for it. It rides the same store write that
/// forgets the entries, so the device list is never published while the name is missing.
func forgetWallet(walletId: String, pendingName: PendingHwWalletName?) async
}

extension TrezorManager: HwDeviceSessioning {
Expand Down
104 changes: 95 additions & 9 deletions Bitkit/Managers/HwWalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ final class HwWalletManager {
private let networkProvider: () -> TrezorCoinType
private let persistSnapshot: @MainActor (HwWalletSnapshot) async throws -> Void
private let deleteActivities: @MainActor (String) async throws -> Void
private let readTagMetadata: @MainActor (String) async throws -> [PreActivityMetadata]
private let writeTagMetadata: @MainActor ([PreActivityMetadata]) async throws -> Void

/// The live device session. Only the identity-aware operations need it; tile and watcher state
/// still come solely from `updateDevices(...)`. Nil in previews and in tests that don't reach
Expand Down Expand Up @@ -93,6 +95,15 @@ final class HwWalletManager {
/// content is byte-identical — that write is what applies the deletions the partial ones deferred.
private var lastPersisted: [String: HwWalletSnapshot] = [:]

/// Tag metadata a removal asked to keep, re-applied after every delete of its wallet. Read at the
/// moment each delete runs rather than captured, so a later removal replaces it — or clears it,
/// when that one keeps nothing.
///
/// Deliberately outlives the removal: a cleanup delete can arrive long afterwards, from a push
/// that re-added the wallet and then dropped it again, and re-applying is what keeps it from
/// taking the rows with it.
private var keptBackupMetadata: [String: [PreActivityMetadata]] = [:]

private var emittedReceivedTxIds: Set<String> = []
private var listeners: [String: TrezorEventListener] = [:]

Expand All @@ -103,7 +114,9 @@ final class HwWalletManager {
electrumUrl: (() -> String)? = nil,
network: (() -> TrezorCoinType)? = nil,
persistSnapshot: (@MainActor (HwWalletSnapshot) async throws -> Void)? = nil,
deleteActivities: (@MainActor (String) async throws -> Void)? = nil
deleteActivities: (@MainActor (String) async throws -> Void)? = nil,
readTagMetadata: (@MainActor (String) async throws -> [PreActivityMetadata])? = nil,
writeTagMetadata: (@MainActor ([PreActivityMetadata]) async throws -> Void)? = nil
) {
self.session = session
self.watcherService = watcherService
Expand All @@ -126,6 +139,12 @@ final class HwWalletManager {
self.deleteActivities = deleteActivities ?? { walletId in
_ = try await CoreService.shared.activity.deleteByWalletId(walletId)
}
self.readTagMetadata = readTagMetadata ?? { walletId in
try await CoreService.shared.activity.tagMetadata(forWallet: walletId)
}
self.writeTagMetadata = writeTagMetadata ?? { records in
try await CoreService.shared.activity.upsertPreActivityMetadata(records)
}
}

// MARK: - Device input
Expand Down Expand Up @@ -162,9 +181,13 @@ final class HwWalletManager {

/// Stop watching a paired hardware wallet and delete its stored activities. Other wallets on the
/// same physical device are left untouched. The caller is responsible for forgetting the stored
/// entries (via `TrezorManager`); the next `updateDevices(...)` push then drops it from the tile
/// list.
func removeDevice(walletId: String) {
/// entries (via `TrezorManager`).
///
/// - Parameter keptMetadata: tag metadata to re-apply after each delete of this wallet, or empty
/// to keep nothing. Passed on every call so a removal that keeps nothing clears what an earlier
/// one left behind.
func removeDevice(walletId: String, keptMetadata: [PreActivityMetadata] = []) {
keptBackupMetadata[walletId] = keptMetadata.isEmpty ? nil : keptMetadata
for watcherId in activeWatchers where self.walletId(fromWatcherId: watcherId) == walletId {
_ = stopActiveWatcher(watcherId)
}
Expand All @@ -173,6 +196,11 @@ final class HwWalletManager {
for device in knownDevices where device.resolvedWalletId == walletId {
walletIdCache[xpubsSignature(device.xpubs)] = nil
}
// Dropped here rather than left to the next `updateDevices(...)` push. Until the wallet leaves
// `hwWalletIds`, the push's own cleanup deletes its activities a second time — after any kept
// metadata was written back — and `deviceGroups()` still yields the group, so a watcher event
// arriving in that window re-persists the activities this just deleted.
knownDevices.removeAll { $0.resolvedWalletId == walletId }
recomputeDerivedState()
}

Expand All @@ -197,9 +225,37 @@ final class HwWalletManager {

/// Removes a hardware wallet and forgets every stored entry that belongs to its wallet identity.
/// Other wallets of the same physical device stay paired.
func removeWallet(walletId: String) async {
removeDevice(walletId: walletId)
await session?.forgetWallet(walletId: walletId)
///
/// - Parameter keepBackupData: whether to carry the wallet's name and tags in the backup, so
/// re-pairing the device restores them. Core deletes a wallet's activities, its activity tags and
/// its pre-activity metadata in one cascade, which is both of the sources the metadata envelope
/// draws hardware tags from, so without this a removal silently empties the backup of them.
func removeWallet(walletId: String, keepBackupData: Bool) async throws {
// Everything here reads; nothing has been deleted yet, so a failure leaves the wallet whole.
var keptName: String?
var keptMetadata: [PreActivityMetadata] = []
if keepBackupData {
keptName = entries(for: walletId)
.lazy
.compactMap { $0.customLabel?.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { !$0.isEmpty }
do {
keptMetadata = try await readTagMetadata(walletId)
} catch {
// Refused rather than reported as a failed removal: the wallet is untouched, so the
// choice stays with the user — retry, or remove it without keeping the data.
Logger.error("Failed to read tag metadata of HW wallet '\(walletId)': \(error)", context: "HwWalletManager")
throw HwWalletRemovalError.backupDataUnreadable
}
}

removeDevice(walletId: walletId, keptMetadata: keptMetadata)
// The name rides the same store write that forgets the entries carrying it. A nil name is
// passed deliberately when keeping nothing: it drops a name an earlier removal kept.
await session?.forgetWallet(
walletId: walletId,
pendingName: PendingHwWalletName(walletId: walletId, name: keptName)
)
}

// MARK: - Wallet identity & the device session
Expand Down Expand Up @@ -367,7 +423,11 @@ final class HwWalletManager {
// Reading the accounts of the wrong wallet already stored it; a mistyped passphrase must not
// leave a stray watch-only wallet behind.
if !watchedBefore.contains(opened) {
await removeWallet(walletId: opened)
// The wallet is a real one the user owns, and reading its accounts already stored it —
// consuming any name restored for it into the entry about to be forgotten. Keeping its
// data puts that name back where re-pairing will find it. Failing here must not replace
// the mismatch the caller is waiting for.
try? await removeWallet(walletId: opened, keepBackupData: true)
}
await session.disconnectStaleSession(deviceId: deviceId)
throw HwPassphraseError.mismatch
Expand Down Expand Up @@ -609,11 +669,29 @@ final class HwWalletManager {

private func delete(walletId: String) {
persistQueue.enqueue(walletId: walletId) { [weak self] in
guard let self else { return }
do {
try await self?.deleteActivities(walletId)
try await deleteActivities(walletId)
} catch {
Logger.error("Failed to delete activities for HW wallet '\(walletId)': \(error)", context: "HwWalletManager")
}
await restoreKeptMetadata(walletId: walletId)
}
}

/// Re-apply the tag metadata a removal asked to keep, as the tail of the delete that took it.
/// Core drops a wallet's pre-activity metadata along with its activities whether or not any
/// matched, so this belongs to every delete of the wallet rather than to the removal alone — a
/// later cleanup pass then repairs itself instead of destroying the kept rows. Core re-attaches
/// them once a watcher recreates the activities, so re-pairing the device brings the tags back.
private func restoreKeptMetadata(walletId: String) async {
guard let kept = keptBackupMetadata[walletId], !kept.isEmpty else { return }
do {
try await writeTagMetadata(kept)
} catch {
// The activities are already gone and the watchers already stopped, so there is nothing
// to roll back to and reporting a failed removal would be false. The tags are lost.
Logger.error("Failed to keep tag metadata of HW wallet '\(walletId)': \(error)", context: "HwWalletManager")
}
}

Expand Down Expand Up @@ -1011,6 +1089,14 @@ final class SnapshotPersistQueue {
}
}

/// Failures of a hardware-wallet removal the user can act on.
enum HwWalletRemovalError: Error, Equatable {
/// The removal asked to keep the wallet's backup data, but its tags could not be read. Raised
/// before anything is deleted, so the wallet is untouched and the removal can be retried or
/// repeated without keeping the data.
case backupDataUnreadable
}

/// Failures specific to passphrase (hidden) wallets, mirroring bitkit-android's `HwPassphrase*Error`
/// types. The passphrase itself never appears in any of them.
enum HwPassphraseError: Error, Equatable {
Expand Down
21 changes: 17 additions & 4 deletions Bitkit/Managers/TrezorManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,12 @@ final class TrezorManager {
copy.customLabel = customLabel
return copy
}
// Cleared before the label is written, while the entry still masks it: a pending name left
// behind would resurface the moment the user clears this label, resurrecting a name they
// replaced. Safe to drop first — the entry already carries whatever it adopted.
for walletId in Set(devices.filter(isTarget).compactMap(\.resolvedWalletId)) {
TrezorKnownDeviceStorage.setPendingName(walletId: walletId, name: nil)
}
TrezorKnownDeviceStorage.saveAll(updated)
loadKnownDevices()
}
Expand Down Expand Up @@ -592,6 +598,13 @@ final class TrezorManager {
let identityKey = TrezorKnownDevice.walletKey(for: mergedXpubs, fallback: device.id)
let named = TrezorKnownDeviceMatching.named(in: stored, previous: previous, walletKey: identityKey)

// A name restored from a backup, or kept when this wallet was removed, waits as a pending one
// until the wallet is paired again — which is here. A name set locally wins: it was chosen on
// this device, after the backup was written. Adopting it is all the consuming needed, since
// `loadPendingNames` masks out wallets the device list already names.
let walletId = resolvedWalletId(previous: previous, identityKey: identityKey, xpubs: mergedXpubs, in: stored)
let pendingName = walletId.flatMap { TrezorKnownDeviceStorage.loadPendingNames()[$0] }

let known = TrezorKnownDevice(
id: device.id,
name: device.name ?? "Trezor",
Expand All @@ -601,8 +614,8 @@ final class TrezorManager {
model: device.model ?? deviceFeatures?.model,
lastConnectedAt: Date(),
xpubs: mergedXpubs,
customLabel: named?.customLabel,
walletId: resolvedWalletId(previous: previous, identityKey: identityKey, xpubs: mergedXpubs, in: stored),
customLabel: named?.customLabel ?? pendingName,
walletId: walletId,
passphraseProtected: passphraseProtection(previous: previous),
trezorDeviceId: deviceFeatures?.deviceId ?? previous?.trezorDeviceId
)
Expand Down Expand Up @@ -720,7 +733,7 @@ final class TrezorManager {
/// credentials are keyed by path and shared by every identity of a device, so they are only
/// cleared once none remains — dropping them while a sibling is still paired would leave that
/// wallet unable to reconnect.
func forgetWallet(walletId: String) async {
func forgetWallet(walletId: String, pendingName: PendingHwWalletName? = nil) async {
let stored = TrezorKnownDeviceStorage.loadAll()
let forgotten = stored.filter { $0.resolvedWalletId == walletId }
guard !forgotten.isEmpty else {
Expand All @@ -733,7 +746,7 @@ final class TrezorManager {
await clearCredentials(path: entry.path)
}

TrezorKnownDeviceStorage.saveAll(remaining)
TrezorKnownDeviceStorage.saveAll(remaining, pendingName: pendingName)
loadKnownDevices()
trezorLog("Forgot hardware wallet: \(walletId)")

Expand Down
22 changes: 22 additions & 0 deletions Bitkit/Models/BackupPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@ struct MetadataBackupV1: Codable {
let cache: AppCacheData
let pubkySession: PubkySessionBackupV1?
let pubkyContactProfileOverrides: [String: PubkyProfileData]?
/// User-set hardware wallet names, keyed by bitkit-core wallet id. Nil in envelopes written
/// before this field, and in envelopes from an app that knows no names. Shared with
/// bitkit-android, which spells the key the same way — see its `MetadataBackupV1`.
let hwWalletNames: [String: String]?

init(
version: Int,
createdAt: UInt64,
tagMetadata: [PreActivityMetadata],
cache: AppCacheData,
pubkySession: PubkySessionBackupV1?,
pubkyContactProfileOverrides: [String: PubkyProfileData]?,
hwWalletNames: [String: String]? = nil
) {
self.version = version
self.createdAt = createdAt
self.tagMetadata = tagMetadata
self.cache = cache
self.pubkySession = pubkySession
self.pubkyContactProfileOverrides = pubkyContactProfileOverrides
self.hwWalletNames = hwWalletNames
}
}

struct PubkySessionBackupV1: Codable, Equatable {
Expand Down
3 changes: 3 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
"hardware__remove_button" = "Remove {name}";
"hardware__remove_dialog_title" = "Remove {name}";
"hardware__remove_dialog_text" = "Don't worry, your funds are safe and your coins won't be deleted. Bitkit will simply stop displaying the amounts in the wallet.";
"hardware__remove_dialog_keep" = "Keep name and tags in backup";
"hardware__remove_error" = "Could not remove the hardware wallet. Please try again.";
"hardware__remove_keep_error" = "Could not keep this wallet's tags in your backup. Try again, or remove it without keeping them.";
"cards__buyBitcoin__title" = "Buy";
"cards__buyBitcoin__description" = "Buy some bitcoin";
"cards__btFailed__title" = "Failed";
Expand Down
Loading