Skip to content
Open
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
16 changes: 16 additions & 0 deletions Bitkit/Extensions/TrezorError+Cancellation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,20 @@ extension Error {

return false
}

/// Whether this error is a locked/busy Trezor (typed `TrezorError.DeviceBusy` since bitkit-core 0.3.9).
func isTrezorDeviceBusy() -> Bool {
if let trezorError = self as? TrezorError {
if case .DeviceBusy = trezorError {
return true
}
return false
}

if let appError = self as? AppError, let underlyingError = appError.underlyingError {
return underlyingError.isTrezorDeviceBusy()
}

return false
}
}
78 changes: 6 additions & 72 deletions Bitkit/Managers/TrezorManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -541,10 +541,14 @@ final class TrezorManager {
/// type being genuinely unavailable on this device — e.g. taproot on firmware without BIP86 —
/// which must not block the device, since that absence is stable across reconnects.
private static let transientFailureMarkers = [
"TransportError", "ConnectionError", "DeviceDisconnected", "DeviceBusy", "Timeout", "IoError", "SessionError",
"TransportError", "ConnectionError", "DeviceDisconnected", "Timeout", "IoError", "SessionError",
Comment thread
jvsena42 marked this conversation as resolved.
]

private static func isTransientTransportFailure(_ error: Error) -> Bool {
// A locked device is worth a bounded retry: the user unlocks and the xpub read succeeds.
if error.isTrezorDeviceBusy() {
return true
}
let text = (error as? AppError)?.debugMessage ?? "\(error)"
return transientFailureMarkers.contains { text.contains($0) }
}
Expand Down Expand Up @@ -758,76 +762,6 @@ final class TrezorManager {
// MARK: - Error Handling

private func errorMessage(from error: Error) -> String {
// ServiceQueue wraps all errors in AppError, so extract the original message
if let appError = error as? AppError {
// debugMessage contains the original error's localizedDescription
if let debugMessage = appError.debugMessage, !debugMessage.isEmpty {
return formatTrezorErrorMessage(debugMessage)
}
// Fall through to the app error message if no debug info
return appError.message
}

if let trezorError = error as? TrezorError {
return trezorError.localizedDescription
}

if let bleError = error as? TrezorBLEError {
return bleError.localizedDescription
}

if let transportError = error as? TrezorTransportError {
return transportError.localizedDescription
}

let description = error.localizedDescription
if description == "The operation couldn't be completed." || description.isEmpty {
return "Connection failed. Please ensure your Trezor is in pairing mode and try again."
}
return description
}

private func formatTrezorErrorMessage(_ message: String) -> String {
let cleanedMessage = message
.replacingOccurrences(of: "Transport error: ", with: "")
.replacingOccurrences(of: "Connection error: ", with: "")
.replacingOccurrences(of: "Protocol error: ", with: "")
.replacingOccurrences(of: "Device error: ", with: "")
.replacingOccurrences(of: "Session error: ", with: "")
.replacingOccurrences(of: "IO error: ", with: "")

if message.contains("Stale Bluetooth pairing") || message.contains("Peer removed pairing") {
return "Stale Bluetooth pairing detected. Go to iOS Settings → Bluetooth, forget your Trezor device, "
+ "then put it back in pairing mode and try again."
}
if message.contains("Unable to open device") || message.contains("Failed to connect") {
return "Failed to connect to Trezor. Please ensure it's in pairing mode and try again."
}
if message.contains("Pairing required") {
return "Bluetooth pairing required. Please put your Trezor in pairing mode."
}
if message.contains("Code verification failed") || message.contains("verification failed") {
return t("hardware__pairing_code_invalid")
}
if message.contains("DeviceBusy") || message.contains("Device is busy") || message.contains("DeviceLocked") {
return t("hardware__device_busy")
}
if message.contains("Pairing failed") || message.contains("Invalid credentials") {
return "Pairing failed. Please try putting your Trezor back in pairing mode."
}
if message.contains("THP handshake failed") {
return "Connection handshake failed. Please disconnect and try again."
}
if message.contains("timed out") || message.contains("Timeout") {
return "Connection timed out. Please try again."
}
if message.contains("Device disconnected") {
return "Trezor disconnected. Please reconnect and try again."
}
if message.contains("Action cancelled") {
return "Action was cancelled on the device."
}

return cleanedMessage
TrezorErrorPresenter.userMessage(from: error)
}
}
81 changes: 81 additions & 0 deletions Bitkit/Utilities/TrezorErrorPresenter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import BitkitCore
import Foundation

/// Maps Trezor-related errors to user-facing messages, shared by `TrezorManager` and `TrezorViewModel`.
enum TrezorErrorPresenter {
static func userMessage(from error: Error) -> String {
// Classify the typed busy error before falling back to matching the stringified message.
if error.isTrezorDeviceBusy() {
return t("hardware__device_busy")
}

if let appError = error as? AppError {
if let debugMessage = appError.debugMessage, !debugMessage.isEmpty {
return mapMessage(debugMessage)
}
return appError.message
}

if let trezorError = error as? TrezorError {
return trezorError.localizedDescription
}

if let bleError = error as? TrezorBLEError {
return bleError.localizedDescription
}

if let transportError = error as? TrezorTransportError {
return transportError.localizedDescription
}

let description = error.localizedDescription
if description == "The operation couldn't be completed." || description.isEmpty {
return "Connection failed. Please ensure your Trezor is in pairing mode and try again."
}
return description
}

static func mapMessage(_ message: String) -> String {
let cleanedMessage = message
.replacingOccurrences(of: "Transport error: ", with: "")
.replacingOccurrences(of: "Connection error: ", with: "")
.replacingOccurrences(of: "Protocol error: ", with: "")
.replacingOccurrences(of: "Device error: ", with: "")
.replacingOccurrences(of: "Session error: ", with: "")
.replacingOccurrences(of: "IO error: ", with: "")

if message.contains("Stale Bluetooth pairing") || message.contains("Peer removed pairing") {
return "Stale Bluetooth pairing detected. Go to iOS Settings → Bluetooth, forget your Trezor device, "
+ "then put it back in pairing mode and try again."
}
if message.contains("Unable to open device") || message.contains("Failed to connect") {
return "Failed to connect to Trezor. Please ensure it's in pairing mode and try again."
}
if message.contains("Pairing required") {
return "Bluetooth pairing required. Please put your Trezor in pairing mode."
}
if message.contains("Code verification failed") {
return t("hardware__pairing_code_invalid")
}
if message.contains("DeviceBusy") || message.contains("Device is busy") {
return t("hardware__device_busy")
}
Comment thread
jvsena42 marked this conversation as resolved.
if message.contains("Pairing failed") || message.contains("Invalid credentials") {
return "Pairing failed. Please try putting your Trezor back in pairing mode."
}
if message.contains("THP handshake failed") {
return "Connection handshake failed. Please disconnect and try again."
}
if message.contains("timed out") || message.contains("Timeout") {
return "Connection timed out. Please try again."
}
if message.contains("Device disconnected") {
return "Trezor disconnected. Please reconnect and try again."
}
if message.contains("Action cancelled") {
return "Action was cancelled on the device."
}

return cleanedMessage
}
}
74 changes: 1 addition & 73 deletions Bitkit/ViewModels/Trezor/TrezorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -957,79 +957,7 @@ class TrezorViewModel {

/// Extract a user-friendly error message from a Trezor error
private func errorMessage(from error: Error) -> String {
// ServiceQueue wraps all errors in AppError, so extract the original message
if let appError = error as? AppError {
// debugMessage contains the original error's localizedDescription
if let debugMessage = appError.debugMessage, !debugMessage.isEmpty {
// Check for common Trezor error patterns in the debug message
return formatTrezorErrorMessage(debugMessage)
}
// Fall through to show the app error message if no debug info
return appError.message
}

// Handle TrezorError directly (if not wrapped)
if let trezorError = error as? TrezorError {
return trezorError.localizedDescription
}

// Handle TrezorBLEError from BLE layer
if let bleError = error as? TrezorBLEError {
return bleError.localizedDescription
}

// Handle TrezorTransportError from transport layer
if let transportError = error as? TrezorTransportError {
return transportError.localizedDescription
}

// For any other error, try to get a meaningful description
let description = error.localizedDescription
if description == "The operation couldn't be completed." || description.isEmpty {
return "Connection failed. Please ensure your Trezor is in pairing mode and try again."
}
return description
}

/// Format Trezor error messages for user display
private func formatTrezorErrorMessage(_ message: String) -> String {
// Clean up common Trezor error prefixes for better readability
let cleanedMessage = message
.replacingOccurrences(of: "Transport error: ", with: "")
.replacingOccurrences(of: "Connection error: ", with: "")
.replacingOccurrences(of: "Protocol error: ", with: "")
.replacingOccurrences(of: "Device error: ", with: "")
.replacingOccurrences(of: "Session error: ", with: "")
.replacingOccurrences(of: "IO error: ", with: "")

// Map technical messages to user-friendly ones
if message.contains("Stale Bluetooth pairing") || message.contains("Peer removed pairing") {
return "Stale Bluetooth pairing detected. Go to iOS Settings → Bluetooth, forget your Trezor device, then put it back in pairing mode and try again."
}
if message.contains("Unable to open device") || message.contains("Failed to connect") {
return "Failed to connect to Trezor. Please ensure it's in pairing mode and try again."
}
if message.contains("Pairing required") {
return "Bluetooth pairing required. Please put your Trezor in pairing mode."
}
if message.contains("Pairing failed") || message.contains("Invalid credentials") {
return "Pairing failed. Please try putting your Trezor back in pairing mode."
}
if message.contains("THP handshake failed") {
return "Connection handshake failed. Please disconnect and try again."
}
if message.contains("timed out") || message.contains("Timeout") {
return "Connection timed out. Please try again."
}
if message.contains("Device disconnected") {
return "Trezor disconnected. Please reconnect and try again."
}
if message.contains("Action cancelled") {
return "Action was cancelled on the device."
}

// Return the cleaned message if no specific mapping
return cleanedMessage
TrezorErrorPresenter.userMessage(from: error)
}

// MARK: - Event Watcher Operations
Expand Down
54 changes: 54 additions & 0 deletions BitkitTests/TrezorDeviceBusyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
@testable import Bitkit
import BitkitCore
import XCTest

/// Truth table for `Error.isTrezorDeviceBusy()` — the typed `TrezorError.DeviceBusy` a locked
/// device surfaces since bitkit-core 0.3.9.
final class TrezorDeviceBusyTests: XCTestCase {
func testDeviceBusyReturnsTrue() {
XCTAssertTrue(TrezorError.DeviceBusy.isTrezorDeviceBusy())
}

func testNonBusyErrorsReturnFalse() {
XCTAssertFalse(TrezorError.Timeout.isTrezorDeviceBusy())
XCTAssertFalse(TrezorError.DeviceDisconnected.isTrezorDeviceBusy())
XCTAssertFalse(TrezorError.UserCancelled.isTrezorDeviceBusy())
XCTAssertFalse(Bitkit.AppError(message: "sign failed", debugMessage: nil).isTrezorDeviceBusy())
XCTAssertFalse(CancellationError().isTrezorDeviceBusy())
}

/// `ServiceQueue` boxes the raw `TrezorError` into an `AppError`, so the check must see through it.
/// `AppError` is qualified as `Bitkit.AppError` because `Errors.swift` is also compiled into this
/// test target, so an unqualified name would resolve to the duplicate and fail the cast.
func testDeviceBusyWrappedInAppErrorReturnsTrue() {
XCTAssertTrue(Bitkit.AppError(error: TrezorError.DeviceBusy).isTrezorDeviceBusy())
}

func testNonBusyWrappedInAppErrorReturnsFalse() {
XCTAssertFalse(Bitkit.AppError(error: TrezorError.Timeout).isTrezorDeviceBusy())
XCTAssertFalse(Bitkit.AppError(error: TrezorError.UserCancelled).isTrezorDeviceBusy())
}

func testPresenterMapsDeviceBusyToUnlockPrompt() {
XCTAssertEqual(
TrezorErrorPresenter.userMessage(from: Bitkit.AppError(error: TrezorError.DeviceBusy)),
t("hardware__device_busy")
)
}

func testPresenterPassesUnrelatedMessageThrough() {
XCTAssertEqual(TrezorErrorPresenter.mapMessage("some unmapped detail"), "some unmapped detail")
}

func testPresenterMapsPairingCodeFailureToInvalidPrompt() {
XCTAssertEqual(TrezorErrorPresenter.mapMessage("Code verification failed"), t("hardware__pairing_code_invalid"))
}

/// A message-signature verification error shares the "verification failed" phrasing but is not a
/// pairing failure, so it must fall through rather than showing the pairing-code prompt.
func testPresenterDoesNotMapMessageVerificationToPairingPrompt() {
let message = "Bitcoin message verification failed"
XCTAssertNotEqual(TrezorErrorPresenter.mapMessage(message), t("hardware__pairing_code_invalid"))
XCTAssertEqual(TrezorErrorPresenter.mapMessage(message), message)
}
}
Loading