diff --git a/Bitkit/Extensions/TrezorDevice+DisplayName.swift b/Bitkit/Extensions/TrezorDevice+DisplayName.swift index 34381600..bef96a1b 100644 --- a/Bitkit/Extensions/TrezorDevice+DisplayName.swift +++ b/Bitkit/Extensions/TrezorDevice+DisplayName.swift @@ -1,8 +1,10 @@ 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)" @@ -10,7 +12,7 @@ func resolveHwWalletName(label: String?, model: String?) -> String { extension TrezorKnownDevice { var displayName: String { - resolveHwWalletName(label: label, model: model) + resolveHwWalletName(label: label, model: model, customLabel: customLabel) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 62d5c6ad..efb93558 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -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. @@ -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() diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 729dbc6c..3485dc61 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -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 ?? "")") + } + /// 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 @@ -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 diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index b56a6044..9433138f 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -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"; diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift index 63f22d64..e2407b59 100644 --- a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift +++ b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift @@ -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, @@ -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 @@ -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 { @@ -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) } } @@ -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() diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 69503430..9c5d300c 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -76,6 +76,7 @@ enum Route: Hashable { case notifications case notificationsIntro case paymentPreference + case hardwareWalletsSettings // Security case dataBackups diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index f9e53c39..e3228cbf 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -26,6 +26,7 @@ enum SheetID: String, CaseIterable { case widgets case hardwareIntro case hardwarePairing + case renameHardwareWallet } struct SheetConfiguration { @@ -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 } diff --git a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift new file mode 100644 index 00000000..6e07cc2b --- /dev/null +++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift @@ -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) +} diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index ac80c34f..1ac89ef1 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -5,6 +5,8 @@ struct GeneralSettingsView: View { @AppStorage(PublicPaykitService.lightningPaymentOptionEnabledKey) private var lightningPaymentOptionEnabled = true @AppStorage(PublicPaykitService.onchainPaymentOptionEnabledKey) private var onchainPaymentOptionEnabled = true + @Environment(HwWalletManager.self) private var hwWalletManager + @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager @@ -110,6 +112,15 @@ struct GeneralSettingsView: View { ) } .accessibilityIdentifier("NotificationsSettings") + + NavigationLink(value: Route.hardwareWalletsSettings) { + SettingsRow( + title: t("settings__hardware_wallets__nav_title"), + iconName: "device-mobile-speaker", + rightText: String(hwWalletManager.wallets.count) + ) + } + .accessibilityIdentifier("HardwareWalletsSettings") } .padding(.top, 16) .padding(.horizontal, 16) @@ -138,6 +149,7 @@ struct GeneralSettingsView: View { .environmentObject(CurrencyViewModel()) .environmentObject(PubkyProfileManager()) .environmentObject(AppViewModel()) + .environment(HwWalletManager()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift b/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift new file mode 100644 index 00000000..d13e4e75 --- /dev/null +++ b/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct RenameHardwareWalletConfig { + let deviceId: String + let currentName: String +} + +struct RenameHardwareWalletSheetItem: SheetItem, Equatable { + let id: SheetID = .renameHardwareWallet + let size: SheetSize = .small + let deviceId: String + let currentName: String +} + +/// Renames a paired hardware wallet: a single NAME field pre-filled with the current name and a Save +/// button. Persists the custom name via `TrezorManager.renameDevice`, which re-pushes the device +/// snapshot so `HwWallet.name` updates everywhere. +struct RenameHardwareWalletSheet: View { + @Environment(TrezorManager.self) private var trezorManager + @EnvironmentObject private var sheets: SheetViewModel + + let config: RenameHardwareWalletSheetItem + + @State private var name: String = "" + @FocusState private var isNameFocused: Bool + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + Sheet(id: .renameHardwareWallet) { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("settings__hardware_wallets__rename_title")) + + CaptionMText(t("settings__hardware_wallets__name_label")) + .padding(.bottom, 8) + + TextField( + config.currentName, + text: $name, + testIdentifier: "RenameHardwareWalletInput" + ) + .focused($isNameFocused) + .submitLabel(.done) + .onSubmit(save) + + Spacer(minLength: 16) + + CustomButton( + title: t("common__save"), + isDisabled: trimmedName.isEmpty, + shouldExpand: true + ) { + save() + } + .buttonBottomPadding(isFocused: isNameFocused) + .accessibilityIdentifier("RenameHardwareWalletSave") + } + .padding(.horizontal, 16) + .task { + name = config.currentName + isNameFocused = true + } + } + } + + private func save() { + guard !trimmedName.isEmpty else { return } + trezorManager.renameDevice(id: config.deviceId, newName: trimmedName) + sheets.hideSheet() + } +} diff --git a/BitkitTests/HwWalletNameTests.swift b/BitkitTests/HwWalletNameTests.swift index 11b59bc9..5b2f28fc 100644 --- a/BitkitTests/HwWalletNameTests.swift +++ b/BitkitTests/HwWalletNameTests.swift @@ -25,4 +25,25 @@ final class HwWalletNameTests: XCTestCase { func testEmptyLabelFallsBackToModel() { XCTAssertEqual(resolveHwWalletName(label: "", model: "Safe 5"), "Trezor Safe 5") } + + func testCustomLabelTakesPriorityOverLabelAndModel() { + XCTAssertEqual( + resolveHwWalletName(label: "My Trezor", model: "Safe 5", customLabel: "Cold Storage"), + "Cold Storage" + ) + } + + func testEmptyCustomLabelFallsBackToLabel() { + XCTAssertEqual( + resolveHwWalletName(label: "My Trezor", model: "Safe 5", customLabel: ""), + "My Trezor" + ) + } + + func testNilCustomLabelFallsBackToPrefixedModel() { + XCTAssertEqual( + resolveHwWalletName(label: nil, model: "Safe 5", customLabel: nil), + "Trezor Safe 5" + ) + } } diff --git a/changelog.d/next/612.added.md b/changelog.d/next/612.added.md new file mode 100644 index 00000000..7a438c2b --- /dev/null +++ b/changelog.d/next/612.added.md @@ -0,0 +1 @@ +Added a Hardware Wallets settings screen, reachable from Settings ▸ General ▸ Payments, to view, rename, and remove paired devices.