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
10 changes: 6 additions & 4 deletions Bitkit/Extensions/TrezorDevice+DisplayName.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import BitkitCore

/// Canonical Trezor display name: the user-set label when it differs from the factory model,
/// otherwise the vendor-prefixed model, falling back to "Trezor".
func resolveHwWalletName(label: String?, model: String?) -> String {
/// Canonical Trezor display name: the Bitkit-side custom name when set, otherwise the device's own
/// label when it differs from the factory model, otherwise the vendor-prefixed model, falling back
/// to "Trezor".
func resolveHwWalletName(label: String?, model: String?, customLabel: String? = nil) -> String {
if let customLabel, !customLabel.isEmpty { return customLabel }
if let label, !label.isEmpty, label != model { return label }
guard let model else { return "Trezor" }
return model.hasPrefix("Trezor") ? model : "Trezor \(model)"
}

extension TrezorKnownDevice {
var displayName: String {
resolveHwWalletName(label: label, model: model)
resolveHwWalletName(label: label, model: model, customLabel: customLabel)
}
}

Expand Down
9 changes: 9 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ struct MainNavView: View {
) {
config in HardwarePairingSheet(config: config)
}
.sheet(
item: $sheets.renameHardwareWalletSheetItem,
onDismiss: {
sheets.hideSheet()
}
) {
config in RenameHardwareWalletSheet(config: config)
}
.onChange(of: trezorManager.showPairingCode) { _, needsCode in
// A hardware device asked for its one-time pairing code (e.g. during reconnect);
// surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled.
Expand Down Expand Up @@ -540,6 +548,7 @@ struct MainNavView: View {
case .notificationsIntro: NotificationsIntro()
case .paymentPreference:
if isPaykitUIActive { PaymentPreferenceView() } else { paykitDisabledRedirectView }
case .hardwareWalletsSettings: HardwareWalletsSettingsScreen()

// Security settings
case .changePin: ChangePinScreen()
Expand Down
24 changes: 24 additions & 0 deletions Bitkit/Managers/TrezorManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,29 @@ final class TrezorManager {
knownDevices = TrezorKnownDeviceStorage.loadAll()
}

/// Set the Bitkit-side custom name for a paired device. The name is trimmed and capped; an empty
/// result clears the custom name (falling back to the device label/model). Applies to every stored
/// entry sharing the target's xpub set so the same device renamed over either transport stays
/// consistent, then reloads so the snapshot re-pushes and `HwWallet.name` updates.
func renameDevice(id: String, newName: String) {
let devices = TrezorKnownDeviceStorage.loadAll()
guard let target = devices.first(where: { $0.id == id }) else { return }

let trimmed = String(newName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.deviceLabelMaxLength))
let customLabel = trimmed.isEmpty ? nil : trimmed

let updated = devices.map { device -> TrezorKnownDevice in
let sameGroup = device.id == id || (!target.xpubs.isEmpty && device.xpubs == target.xpubs)
guard sameGroup else { return device }
var copy = device
copy.customLabel = customLabel
return copy
}
TrezorKnownDeviceStorage.saveAll(updated)
loadKnownDevices()
trezorLog("Renamed device \(id) to \(customLabel ?? "<default>")")
}

/// Captures the connected device's account xpubs so watch-only balances/activity stay available
/// while disconnected. The watch-only wallet id is derived from the captured xpub set, so a save
/// is blocked only when an address type failed *transiently* (a retryable transport error) and
Expand Down Expand Up @@ -474,6 +497,7 @@ final class TrezorManager {

private static let maxXpubFetchAttempts = 3
private static let xpubFetchRetryDelayNanos: UInt64 = 300_000_000
private static let deviceLabelMaxLength = 50

/// Markers (matched against the underlying `TrezorError` carried in the wrapped error's text)
/// for transient transport problems worth retrying. Anything else is treated as the address
Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,10 @@
"settings__general__language_other" = "Interface language";
"settings__general__section_interface" = "Interface";
"settings__general__section_payments" = "Payments";
"settings__hardware_wallets__nav_title" = "Hardware Wallets";
"settings__hardware_wallets__add_button" = "Add Hardware Wallet";
"settings__hardware_wallets__rename_title" = "Rename Hardware Wallet";
"settings__hardware_wallets__name_label" = "Name";
"settings__widgets__nav_title" = "Widgets";
"settings__widgets__section_display" = "Display";
"settings__widgets__section_reset" = "Reset To Defaults";
Expand Down
16 changes: 15 additions & 1 deletion Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ struct TrezorKnownDevice: Codable, Identifiable {
/// Account-level extended public keys keyed by `AddressScriptType.stringValue`.
/// Persisted so watch-only balances/activity stay available while disconnected.
var xpubs: [String: String]
/// User-set name applied while managing the wallet in Bitkit; nil until renamed. Takes priority
/// over the device's own `label`/`model` when resolving the display name.
var customLabel: String?

init(
id: String,
Expand All @@ -21,7 +24,8 @@ struct TrezorKnownDevice: Codable, Identifiable {
label: String? = nil,
model: String? = nil,
lastConnectedAt: Date,
xpubs: [String: String] = [:]
xpubs: [String: String] = [:],
customLabel: String? = nil
) {
self.id = id
self.name = name
Expand All @@ -31,6 +35,7 @@ struct TrezorKnownDevice: Codable, Identifiable {
self.model = model
self.lastConnectedAt = lastConnectedAt
self.xpubs = xpubs
self.customLabel = customLabel
}

init(from decoder: any Decoder) throws {
Expand All @@ -43,6 +48,7 @@ struct TrezorKnownDevice: Codable, Identifiable {
model = try container.decodeIfPresent(String.self, forKey: .model)
lastConnectedAt = try container.decode(Date.self, forKey: .lastConnectedAt)
xpubs = try container.decodeIfPresent([String: String].self, forKey: .xpubs) ?? [:]
customLabel = try container.decodeIfPresent(String.self, forKey: .customLabel)
}
}

Expand All @@ -68,6 +74,14 @@ enum TrezorKnownDeviceStorage {
}
}

/// Persist the full device list as-is. Used for bulk updates (e.g. renaming every entry of a
/// device shared across transports) without per-device reordering.
static func saveAll(_ devices: [TrezorKnownDevice]) {
if let data = try? JSONEncoder().encode(devices) {
UserDefaults.standard.set(data, forKey: key)
}
}

/// Remove a known device by ID
static func remove(id: String) {
var devices = loadAll()
Expand Down
1 change: 1 addition & 0 deletions Bitkit/ViewModels/NavigationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ enum Route: Hashable {
case notifications
case notificationsIntro
case paymentPreference
case hardwareWalletsSettings

// Security
case dataBackups
Expand Down
14 changes: 14 additions & 0 deletions Bitkit/ViewModels/SheetViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ enum SheetID: String, CaseIterable {
case widgets
case hardwareIntro
case hardwarePairing
case renameHardwareWallet
}

struct SheetConfiguration {
Expand Down Expand Up @@ -327,6 +328,19 @@ class SheetViewModel: ObservableObject {
}
}

var renameHardwareWalletSheetItem: RenameHardwareWalletSheetItem? {
get {
guard let config = activeSheetConfiguration, config.id == .renameHardwareWallet else { return nil }
guard let data = config.data as? RenameHardwareWalletConfig else { return nil }
return RenameHardwareWalletSheetItem(deviceId: data.deviceId, currentName: data.currentName)
}
set {
if newValue == nil {
activeSheetConfiguration = nil
}
}
}

var scannerSheetItem: ScannerSheetItem? {
get {
guard let config = activeSheetConfiguration, config.id == .scanner else { return nil }
Expand Down
194 changes: 194 additions & 0 deletions Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import SwiftUI

/// Manages all paired hardware wallets, reachable from Settings ▸ General ▸ Payments. Lists each
/// paired device with a connection badge, name, balance and a per-row delete; an intro-style empty
/// state when none are paired; and an "Add Hardware Wallet" button that opens the existing intro
/// flow. Tapping a row opens the device's `HardwareWalletScreen`.
struct HardwareWalletsSettingsScreen: View {
@Environment(HwWalletManager.self) private var hwWalletManager
@Environment(TrezorManager.self) private var trezorManager
@EnvironmentObject private var sheets: SheetViewModel

@State private var pendingRemoval: HwWallet?

private var wallets: [HwWallet] {
hwWalletManager.wallets
}

var body: some View {
VStack(spacing: 0) {
NavigationBar(title: t("settings__hardware_wallets__nav_title"))
.padding(.horizontal, 16)

if wallets.isEmpty {
emptyState
} else {
deviceList
}

CustomButton(
title: t("settings__hardware_wallets__add_button"),
shouldExpand: true
) {
sheets.showSheet(.hardwareIntro)
}
.accessibilityIdentifier("AddHardwareWallet")
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
.navigationBarHidden(true)
.accessibilityIdentifier("HardwareWalletsScreen")
.alert(
t("hardware__remove_dialog_title", variables: ["name": pendingRemoval?.name ?? ""]),
isPresented: Binding(get: { pendingRemoval != nil }, set: { if !$0 { pendingRemoval = nil } })
) {
Button(t("common__remove"), role: .destructive) {
guard let wallet = pendingRemoval else { return }
Task { await remove(wallet) }
}
Button(t("common__dialog_cancel"), role: .cancel) {}
} message: {
Text(t("hardware__remove_dialog_text"))
}
}

private var emptyState: some View {
VStack(spacing: 0) {
HwDeviceIllustrations()
.frame(maxWidth: .infinity, maxHeight: .infinity)

VStack(alignment: .leading, spacing: 8) {
DisplayText(t("hardware__intro_header"), accentColor: .blueAccent)
BodyMText(t("hardware__intro_text"))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.bottom, 32)
}
}

private var deviceList: some View {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(wallets) { wallet in
HwWalletRow(
wallet: wallet,
onRename: {
sheets.showSheet(
.renameHardwareWallet,
data: RenameHardwareWalletConfig(deviceId: wallet.id, currentName: wallet.name)
)
},
onRemove: { pendingRemoval = wallet }
)
CustomDivider()
}
}
.padding(.top, 14)
.padding(.horizontal, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(alignment: .bottom) {
HwDeviceIllustrations()
.frame(height: 256)
.padding(.bottom, 59.77)
.allowsHitTesting(false)
}
}

private func remove(_ wallet: HwWallet) async {
pendingRemoval = nil
hwWalletManager.removeDevice(id: wallet.id)
for id in wallet.deviceIds {
await trezorManager.forgetDevice(id: id)
}
}
}

/// A paired hardware wallet row: connection badge, name, balance and a trailing delete. Tapping the
/// name opens the rename sheet; the trash button removes the device.
private struct HwWalletRow: View {
let wallet: HwWallet
let onRename: () -> Void
let onRemove: () -> Void

var body: some View {
HStack(spacing: 12) {
HwConnectionBadge(isConnected: wallet.isConnected)

Button(action: onRename) {
BodyMText(wallet.name, textColor: .textPrimary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("HardwareWalletRowName_\(wallet.id)")

MoneyText(
sats: Int(clamping: wallet.balanceSats),
size: .bodyMSB,
symbol: true,
color: .white64,
symbolColor: .white64
)

Button(action: onRemove) {
Image("trash")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
.foregroundColor(.white)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("HardwareWalletRowDelete_\(wallet.id)")
}
.frame(height: 50)
.accessibilityIdentifier("HardwareWalletRow_\(wallet.id)")
}
}

/// Circular connection badge tinting the shared Bluetooth glyph green when connected, gray otherwise.
private struct HwConnectionBadge: View {
let isConnected: Bool

var body: some View {
ZStack {
Circle()
.fill(isConnected ? Color.green16 : Color.white16)
.frame(width: 32, height: 32)

HwWalletConnectionIcon(isConnected: isConnected)
.frame(width: 16, height: 16)
}
}
}

#Preview("With devices") {
NavigationStack {
HardwareWalletsSettingsScreen()
.environment(HwWalletManager())
.environment(TrezorManager())
.environmentObject(SheetViewModel())
.environmentObject(NavigationViewModel())
.environmentObject(CurrencyViewModel())
.environmentObject(SettingsViewModel.shared)
}
.preferredColorScheme(.dark)
}

#Preview("Empty") {
NavigationStack {
HardwareWalletsSettingsScreen()
.environment(HwWalletManager())
.environment(TrezorManager())
.environmentObject(SheetViewModel())
.environmentObject(NavigationViewModel())
.environmentObject(CurrencyViewModel())
.environmentObject(SettingsViewModel.shared)
}
.preferredColorScheme(.dark)
}
Loading