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
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 @@ -204,6 +204,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 @@ -531,6 +539,7 @@ struct MainNavView: View {
case .notificationsIntro: NotificationsIntro()
case .paymentPreference:
if isPaykitUIActive { PaymentPreferenceView() } else { paykitDisabledRedirectView }
case .hardwareWalletsSettings: HardwareWalletsSettingsScreen()

// Security settings
case .changePin: ChangePinScreen()
Expand Down
51 changes: 50 additions & 1 deletion Bitkit/Managers/TrezorManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ final class TrezorManager {
}
.store(in: &cancellables)

// A spontaneous BLE drop (device out of range or phone Bluetooth turned off)
// must clear the live session.
transport.externalDisconnectPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] path in
guard let self, connectedDevice?.path == path else { return }
trezorLog("External disconnect for \(path); clearing session")
clearDisconnectedDeviceState()
}
.store(in: &cancellables)

// Passphrase entry is now driven proactively by the wallet-mode selector
// (see setWalletMode / requestPassphraseWallet). The device callback
// `onPassphraseRequest` is answered silently from the selected mode, so there
Expand Down Expand Up @@ -427,6 +438,42 @@ final class TrezorManager {
knownDevices = TrezorKnownDeviceStorage.loadAll()
}

/// Display name for the currently connected device, applying any Bitkit-side custom rename (from
/// the stored known-device record) over the device's own label/model — mirrors how watch-only
/// tiles resolve names, so a rename shows on the connected-device screen too.
var connectedDeviceDisplayName: String? {
guard let device = connectedDevice else { return nil }
let customLabel = knownDevices.first { $0.id == device.id }?.customLabel
return resolveHwWalletName(
label: device.label ?? deviceFeatures?.label,
model: device.model ?? deviceFeatures?.model,
customLabel: customLabel
)
}

/// 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 @@ -464,7 +511,8 @@ final class TrezorManager {
label: device.label ?? deviceFeatures?.label,
model: device.model ?? deviceFeatures?.model,
lastConnectedAt: Date(),
xpubs: mergedXpubs
xpubs: mergedXpubs,
customLabel: previous?.customLabel
)
TrezorKnownDeviceStorage.save(known)
loadKnownDevices()
Expand All @@ -474,6 +522,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
49 changes: 48 additions & 1 deletion Bitkit/Services/Trezor/TrezorBLEManager.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import CoreBluetooth
import Foundation
import Observation
Expand Down Expand Up @@ -47,6 +48,15 @@ class TrezorBLEManager: NSObject {
private var connectedPeripheral: CBPeripheral?
private var connectedPath: String?

/// Paths whose disconnect was user-initiated (via `disconnect(path:)`), so the
/// spontaneous-disconnect signal is suppressed for deliberate teardowns.
private var expectedDisconnectPaths: Set<String> = []

/// Emits the device path when an established connection drops for a reason the
/// user did not request — device out of range or phone Bluetooth turned off.
/// `TrezorManager` subscribes (via `TrezorTransport`) to clear the stale session.
let externalDisconnectPublisher = PassthroughSubject<String, Never>()

/// GATT characteristics
private var writeCharacteristic: CBCharacteristic?
private var notifyCharacteristic: CBCharacteristic?
Expand Down Expand Up @@ -284,6 +294,10 @@ class TrezorBLEManager: NSObject {
// Clean up any stale connection state (preserves discoveredPeripherals cache)
cleanupConnectionState()

// Drop any stale deliberate-disconnect marker from a prior session so it can't
// suppress a future spontaneous drop on this reused path.
expectedDisconnectPaths.remove(path)

debugLog("connectOnce: \(path)")

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

debugLog("Disconnecting: \(path)")

// Mark this as a deliberate teardown so the resulting didDisconnect callback
// does not surface as a spontaneous (out-of-range) drop.
expectedDisconnectPaths.insert(path)
Comment thread
jvsena42 marked this conversation as resolved.

if let notifyChar = notifyCharacteristic {
peripheral.setNotifyValue(false, for: notifyChar)
}
Expand Down Expand Up @@ -550,6 +568,20 @@ extension TrezorBLEManager: CBCentralManagerDelegate {
self.isScanning = false
}
}

// Bluetooth turned off / unauthorized invalidates any live connection. iOS does
// not guarantee a per-peripheral didDisconnect on power-off, so treat it as a
// spontaneous drop here: clear internal state and surface the disconnect.
if central.state != .poweredOn, let path = connectedPath {
connectedPeripheral = nil
connectedPath = nil
writeCharacteristic = nil
notifyCharacteristic = nil
readQueue.clear()
if expectedDisconnectPaths.remove(path) == nil {
externalDisconnectPublisher.send(path)
}
}
}

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

func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
func centralManager(
_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?
) {
debugLog("didDisconnect: \(peripheral.identifier)\(error.map { " error: \($0.localizedDescription)" } ?? "")")

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

let path = "ble:\(peripheral.identifier.uuidString)"
// Consume any deliberate-disconnect marker even when disconnect(path:) already
// cleared connection state, so it can't be mistaken for a future real drop.
let wasExpected = expectedDisconnectPaths.remove(path) != nil

if connectedPeripheral?.identifier == peripheral.identifier {
connectedPeripheral = nil
connectedPath = nil
writeCharacteristic = nil
notifyCharacteristic = nil
readQueue.clear()

// Surface a spontaneous drop (out of range) so the session can be cleared.
// A deliberate disconnect(path:) is suppressed via expectedDisconnectPaths.
if !wasExpected {
externalDisconnectPublisher.send(path)
}
}
}
}
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
Comment thread
jvsena42 marked this conversation as resolved.
) {
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
6 changes: 6 additions & 0 deletions Bitkit/Services/Trezor/TrezorTransport.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import BitkitCore
import Combine

Check warning on line 2 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

add '@preconcurrency' to suppress 'Sendable'-related warnings from module 'Combine'

Check warning on line 2 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Tests

add '@preconcurrency' to suppress 'Sendable'-related warnings from module 'Combine'
import CoreBluetooth
import Foundation

Expand All @@ -8,15 +8,15 @@
final class TrezorTransport: TrezorTransportCallback {
static let shared = TrezorTransport()

private let bleManager = TrezorBLEManager.shared

Check warning on line 11 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

stored property 'bleManager' of 'Sendable'-conforming class 'TrezorTransport' has non-sendable type 'TrezorBLEManager'; this is an error in the Swift 6 language mode

Check warning on line 11 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Tests

stored property 'bleManager' of 'Sendable'-conforming class 'TrezorTransport' has non-sendable type 'TrezorBLEManager'; this is an error in the Swift 6 language mode
private let bridgeTransport = TrezorBridgeTransport.shared

Check warning on line 12 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

stored property 'bridgeTransport' of 'Sendable'-conforming class 'TrezorTransport' has non-sendable type 'TrezorBridgeTransport'; this is an error in the Swift 6 language mode

Check warning on line 12 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Tests

stored property 'bridgeTransport' of 'Sendable'-conforming class 'TrezorTransport' has non-sendable type 'TrezorBridgeTransport'; this is an error in the Swift 6 language mode

// MARK: - Pairing Code Handling

/// Subject to notify UI when pairing code is needed
let needsPairingCodePublisher = PassthroughSubject<Void, Never>()

Check warning on line 17 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

stored property 'needsPairingCodePublisher' of 'Sendable'-conforming class 'TrezorTransport' has non-sendable type 'PassthroughSubject<Void, Never>'; this is an error in the Swift 6 language mode

Check warning on line 17 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Tests

stored property 'needsPairingCodePublisher' of 'Sendable'-conforming class 'TrezorTransport' has non-sendable type 'PassthroughSubject<Void, Never>'; this is an error in the Swift 6 language mode

private var submittedPairingCode: String = ""

Check warning on line 19 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

stored property 'submittedPairingCode' of 'Sendable'-conforming class 'TrezorTransport' is mutable; this is an error in the Swift 6 language mode

Check warning on line 19 in Bitkit/Services/Trezor/TrezorTransport.swift

View workflow job for this annotation

GitHub Actions / Run Tests

stored property 'submittedPairingCode' of 'Sendable'-conforming class 'TrezorTransport' is mutable; this is an error in the Swift 6 language mode
private let pairingCodeLock = NSLock()

/// Timeout for pairing code entry (2 minutes)
Expand Down Expand Up @@ -305,6 +305,12 @@
bleManager.bluetoothState
}

/// Emits a device path when an established BLE connection drops unexpectedly
/// (out of range or phone Bluetooth turned off).
var externalDisconnectPublisher: PassthroughSubject<String, Never> {
bleManager.externalDisconnectPublisher
}

var isBridgeEnabled: Bool {
bridgeTransport.isEnabled
}
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
Loading
Loading