diff --git a/Bitkit/Extensions/TrezorError+Cancellation.swift b/Bitkit/Extensions/TrezorError+Cancellation.swift index 328150ff..9fa32f4a 100644 --- a/Bitkit/Extensions/TrezorError+Cancellation.swift +++ b/Bitkit/Extensions/TrezorError+Cancellation.swift @@ -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 + } } diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 71eadebc..5ec5f813 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -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", ] 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) } } @@ -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) } } diff --git a/Bitkit/Utilities/TrezorErrorPresenter.swift b/Bitkit/Utilities/TrezorErrorPresenter.swift new file mode 100644 index 00000000..aea99078 --- /dev/null +++ b/Bitkit/Utilities/TrezorErrorPresenter.swift @@ -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") + } + 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 + } +} diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift index b8e4e580..24b44886 100644 --- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -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 diff --git a/BitkitTests/TrezorDeviceBusyTests.swift b/BitkitTests/TrezorDeviceBusyTests.swift new file mode 100644 index 00000000..1870f6c6 --- /dev/null +++ b/BitkitTests/TrezorDeviceBusyTests.swift @@ -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) + } +}