Skip to content

Commit c0e5f2a

Browse files
authored
Merge pull request #612 from synonymdev/feat/hw-wallet-settings
feat: hardware wallets settings screen
2 parents 6299809 + cbecdac commit c0e5f2a

15 files changed

Lines changed: 458 additions & 11 deletions

Bitkit/Extensions/TrezorDevice+DisplayName.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import BitkitCore
22

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

1113
extension TrezorKnownDevice {
1214
var displayName: String {
13-
resolveHwWalletName(label: label, model: model)
15+
resolveHwWalletName(label: label, model: model, customLabel: customLabel)
1416
}
1517
}
1618

Bitkit/MainNavView.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,14 @@ struct MainNavView: View {
204204
) {
205205
config in HardwarePairingSheet(config: config)
206206
}
207+
.sheet(
208+
item: $sheets.renameHardwareWalletSheetItem,
209+
onDismiss: {
210+
sheets.hideSheet()
211+
}
212+
) {
213+
config in RenameHardwareWalletSheet(config: config)
214+
}
207215
.onChange(of: trezorManager.showPairingCode) { _, needsCode in
208216
// A hardware device asked for its one-time pairing code (e.g. during reconnect);
209217
// surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled.
@@ -531,6 +539,7 @@ struct MainNavView: View {
531539
case .notificationsIntro: NotificationsIntro()
532540
case .paymentPreference:
533541
if isPaykitUIActive { PaymentPreferenceView() } else { paykitDisabledRedirectView }
542+
case .hardwareWalletsSettings: HardwareWalletsSettingsScreen()
534543

535544
// Security settings
536545
case .changePin: ChangePinScreen()

Bitkit/Managers/TrezorManager.swift

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@ final class TrezorManager {
125125
}
126126
.store(in: &cancellables)
127127

128+
// A spontaneous BLE drop (device out of range or phone Bluetooth turned off)
129+
// must clear the live session.
130+
transport.externalDisconnectPublisher
131+
.receive(on: DispatchQueue.main)
132+
.sink { [weak self] path in
133+
guard let self, connectedDevice?.path == path else { return }
134+
trezorLog("External disconnect for \(path); clearing session")
135+
clearDisconnectedDeviceState()
136+
}
137+
.store(in: &cancellables)
138+
128139
// Passphrase entry is now driven proactively by the wallet-mode selector
129140
// (see setWalletMode / requestPassphraseWallet). The device callback
130141
// `onPassphraseRequest` is answered silently from the selected mode, so there
@@ -427,6 +438,42 @@ final class TrezorManager {
427438
knownDevices = TrezorKnownDeviceStorage.loadAll()
428439
}
429440

441+
/// Display name for the currently connected device, applying any Bitkit-side custom rename (from
442+
/// the stored known-device record) over the device's own label/model — mirrors how watch-only
443+
/// tiles resolve names, so a rename shows on the connected-device screen too.
444+
var connectedDeviceDisplayName: String? {
445+
guard let device = connectedDevice else { return nil }
446+
let customLabel = knownDevices.first { $0.id == device.id }?.customLabel
447+
return resolveHwWalletName(
448+
label: device.label ?? deviceFeatures?.label,
449+
model: device.model ?? deviceFeatures?.model,
450+
customLabel: customLabel
451+
)
452+
}
453+
454+
/// Set the Bitkit-side custom name for a paired device. The name is trimmed and capped; an empty
455+
/// result clears the custom name (falling back to the device label/model). Applies to every stored
456+
/// entry sharing the target's xpub set so the same device renamed over either transport stays
457+
/// consistent, then reloads so the snapshot re-pushes and `HwWallet.name` updates.
458+
func renameDevice(id: String, newName: String) {
459+
let devices = TrezorKnownDeviceStorage.loadAll()
460+
guard let target = devices.first(where: { $0.id == id }) else { return }
461+
462+
let trimmed = String(newName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.deviceLabelMaxLength))
463+
let customLabel = trimmed.isEmpty ? nil : trimmed
464+
465+
let updated = devices.map { device -> TrezorKnownDevice in
466+
let sameGroup = device.id == id || (!target.xpubs.isEmpty && device.xpubs == target.xpubs)
467+
guard sameGroup else { return device }
468+
var copy = device
469+
copy.customLabel = customLabel
470+
return copy
471+
}
472+
TrezorKnownDeviceStorage.saveAll(updated)
473+
loadKnownDevices()
474+
trezorLog("Renamed device \(id) to \(customLabel ?? "<default>")")
475+
}
476+
430477
/// Captures the connected device's account xpubs so watch-only balances/activity stay available
431478
/// while disconnected. The watch-only wallet id is derived from the captured xpub set, so a save
432479
/// is blocked only when an address type failed *transiently* (a retryable transport error) and
@@ -464,7 +511,8 @@ final class TrezorManager {
464511
label: device.label ?? deviceFeatures?.label,
465512
model: device.model ?? deviceFeatures?.model,
466513
lastConnectedAt: Date(),
467-
xpubs: mergedXpubs
514+
xpubs: mergedXpubs,
515+
customLabel: previous?.customLabel
468516
)
469517
TrezorKnownDeviceStorage.save(known)
470518
loadKnownDevices()
@@ -474,6 +522,7 @@ final class TrezorManager {
474522

475523
private static let maxXpubFetchAttempts = 3
476524
private static let xpubFetchRetryDelayNanos: UInt64 = 300_000_000
525+
private static let deviceLabelMaxLength = 50
477526

478527
/// Markers (matched against the underlying `TrezorError` carried in the wrapped error's text)
479528
/// for transient transport problems worth retrying. Anything else is treated as the address

Bitkit/Resources/Localization/en.lproj/Localizable.strings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,10 @@
686686
"settings__general__language_other" = "Interface language";
687687
"settings__general__section_interface" = "Interface";
688688
"settings__general__section_payments" = "Payments";
689+
"settings__hardware_wallets__nav_title" = "Hardware Wallets";
690+
"settings__hardware_wallets__add_button" = "Add Hardware Wallet";
691+
"settings__hardware_wallets__rename_title" = "Rename Hardware Wallet";
692+
"settings__hardware_wallets__name_label" = "Name";
689693
"settings__widgets__nav_title" = "Widgets";
690694
"settings__widgets__section_display" = "Display";
691695
"settings__widgets__section_reset" = "Reset To Defaults";

Bitkit/Services/Trezor/TrezorBLEManager.swift

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Combine
12
import CoreBluetooth
23
import Foundation
34
import Observation
@@ -47,6 +48,15 @@ class TrezorBLEManager: NSObject {
4748
private var connectedPeripheral: CBPeripheral?
4849
private var connectedPath: String?
4950

51+
/// Paths whose disconnect was user-initiated (via `disconnect(path:)`), so the
52+
/// spontaneous-disconnect signal is suppressed for deliberate teardowns.
53+
private var expectedDisconnectPaths: Set<String> = []
54+
55+
/// Emits the device path when an established connection drops for a reason the
56+
/// user did not request — device out of range or phone Bluetooth turned off.
57+
/// `TrezorManager` subscribes (via `TrezorTransport`) to clear the stale session.
58+
let externalDisconnectPublisher = PassthroughSubject<String, Never>()
59+
5060
/// GATT characteristics
5161
private var writeCharacteristic: CBCharacteristic?
5262
private var notifyCharacteristic: CBCharacteristic?
@@ -284,6 +294,10 @@ class TrezorBLEManager: NSObject {
284294
// Clean up any stale connection state (preserves discoveredPeripherals cache)
285295
cleanupConnectionState()
286296

297+
// Drop any stale deliberate-disconnect marker from a prior session so it can't
298+
// suppress a future spontaneous drop on this reused path.
299+
expectedDisconnectPaths.remove(path)
300+
287301
debugLog("connectOnce: \(path)")
288302

289303
// Try to get peripheral from cache first
@@ -437,6 +451,10 @@ class TrezorBLEManager: NSObject {
437451

438452
debugLog("Disconnecting: \(path)")
439453

454+
// Mark this as a deliberate teardown so the resulting didDisconnect callback
455+
// does not surface as a spontaneous (out-of-range) drop.
456+
expectedDisconnectPaths.insert(path)
457+
440458
if let notifyChar = notifyCharacteristic {
441459
peripheral.setNotifyValue(false, for: notifyChar)
442460
}
@@ -550,6 +568,20 @@ extension TrezorBLEManager: CBCentralManagerDelegate {
550568
self.isScanning = false
551569
}
552570
}
571+
572+
// Bluetooth turned off / unauthorized invalidates any live connection. iOS does
573+
// not guarantee a per-peripheral didDisconnect on power-off, so treat it as a
574+
// spontaneous drop here: clear internal state and surface the disconnect.
575+
if central.state != .poweredOn, let path = connectedPath {
576+
connectedPeripheral = nil
577+
connectedPath = nil
578+
writeCharacteristic = nil
579+
notifyCharacteristic = nil
580+
readQueue.clear()
581+
if expectedDisconnectPaths.remove(path) == nil {
582+
externalDisconnectPublisher.send(path)
583+
}
584+
}
553585
}
554586

555587
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
@@ -592,7 +624,11 @@ extension TrezorBLEManager: CBCentralManagerDelegate {
592624
takeConnectContinuation()?.resume(throwing: error ?? TrezorBLEError.connectionFailed)
593625
}
594626

595-
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
627+
func centralManager(
628+
_ central: CBCentralManager,
629+
didDisconnectPeripheral peripheral: CBPeripheral,
630+
error: Error?
631+
) {
596632
debugLog("didDisconnect: \(peripheral.identifier)\(error.map { " error: \($0.localizedDescription)" } ?? "")")
597633

598634
let disconnectError = error ?? TrezorBLEError.connectionFailed
@@ -603,12 +639,23 @@ extension TrezorBLEManager: CBCentralManagerDelegate {
603639
takeNotificationContinuation()?.resume(throwing: disconnectError)
604640
takeWriteContinuation()?.resume(throwing: disconnectError)
605641

642+
let path = "ble:\(peripheral.identifier.uuidString)"
643+
// Consume any deliberate-disconnect marker even when disconnect(path:) already
644+
// cleared connection state, so it can't be mistaken for a future real drop.
645+
let wasExpected = expectedDisconnectPaths.remove(path) != nil
646+
606647
if connectedPeripheral?.identifier == peripheral.identifier {
607648
connectedPeripheral = nil
608649
connectedPath = nil
609650
writeCharacteristic = nil
610651
notifyCharacteristic = nil
611652
readQueue.clear()
653+
654+
// Surface a spontaneous drop (out of range) so the session can be cleared.
655+
// A deliberate disconnect(path:) is suppressed via expectedDisconnectPaths.
656+
if !wasExpected {
657+
externalDisconnectPublisher.send(path)
658+
}
612659
}
613660
}
614661
}

Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ struct TrezorKnownDevice: Codable, Identifiable {
1212
/// Account-level extended public keys keyed by `AddressScriptType.stringValue`.
1313
/// Persisted so watch-only balances/activity stay available while disconnected.
1414
var xpubs: [String: String]
15+
/// User-set name applied while managing the wallet in Bitkit; nil until renamed. Takes priority
16+
/// over the device's own `label`/`model` when resolving the display name.
17+
var customLabel: String?
1518

1619
init(
1720
id: String,
@@ -21,7 +24,8 @@ struct TrezorKnownDevice: Codable, Identifiable {
2124
label: String? = nil,
2225
model: String? = nil,
2326
lastConnectedAt: Date,
24-
xpubs: [String: String] = [:]
27+
xpubs: [String: String] = [:],
28+
customLabel: String? = nil
2529
) {
2630
self.id = id
2731
self.name = name
@@ -31,6 +35,7 @@ struct TrezorKnownDevice: Codable, Identifiable {
3135
self.model = model
3236
self.lastConnectedAt = lastConnectedAt
3337
self.xpubs = xpubs
38+
self.customLabel = customLabel
3439
}
3540

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

@@ -68,6 +74,14 @@ enum TrezorKnownDeviceStorage {
6874
}
6975
}
7076

77+
/// Persist the full device list as-is. Used for bulk updates (e.g. renaming every entry of a
78+
/// device shared across transports) without per-device reordering.
79+
static func saveAll(_ devices: [TrezorKnownDevice]) {
80+
if let data = try? JSONEncoder().encode(devices) {
81+
UserDefaults.standard.set(data, forKey: key)
82+
}
83+
}
84+
7185
/// Remove a known device by ID
7286
static func remove(id: String) {
7387
var devices = loadAll()

Bitkit/Services/Trezor/TrezorTransport.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,12 @@ final class TrezorTransport: TrezorTransportCallback {
305305
bleManager.bluetoothState
306306
}
307307

308+
/// Emits a device path when an established BLE connection drops unexpectedly
309+
/// (out of range or phone Bluetooth turned off).
310+
var externalDisconnectPublisher: PassthroughSubject<String, Never> {
311+
bleManager.externalDisconnectPublisher
312+
}
313+
308314
var isBridgeEnabled: Bool {
309315
bridgeTransport.isEnabled
310316
}

Bitkit/ViewModels/NavigationViewModel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ enum Route: Hashable {
7676
case notifications
7777
case notificationsIntro
7878
case paymentPreference
79+
case hardwareWalletsSettings
7980

8081
// Security
8182
case dataBackups

Bitkit/ViewModels/SheetViewModel.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ enum SheetID: String, CaseIterable {
2626
case widgets
2727
case hardwareIntro
2828
case hardwarePairing
29+
case renameHardwareWallet
2930
}
3031

3132
struct SheetConfiguration {
@@ -327,6 +328,19 @@ class SheetViewModel: ObservableObject {
327328
}
328329
}
329330

331+
var renameHardwareWalletSheetItem: RenameHardwareWalletSheetItem? {
332+
get {
333+
guard let config = activeSheetConfiguration, config.id == .renameHardwareWallet else { return nil }
334+
guard let data = config.data as? RenameHardwareWalletConfig else { return nil }
335+
return RenameHardwareWalletSheetItem(deviceId: data.deviceId, currentName: data.currentName)
336+
}
337+
set {
338+
if newValue == nil {
339+
activeSheetConfiguration = nil
340+
}
341+
}
342+
}
343+
330344
var scannerSheetItem: ScannerSheetItem? {
331345
get {
332346
guard let config = activeSheetConfiguration, config.id == .scanner else { return nil }

0 commit comments

Comments
 (0)