diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 05748e0d..218a65f8 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -515,6 +515,10 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.2; + OTHER_LDFLAGS = ( + "-framework", + CoreBluetooth, + ); PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -543,6 +547,10 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.2; + OTHER_LDFLAGS = ( + "-framework", + CoreBluetooth, + ); PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -675,6 +683,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; @@ -702,8 +711,13 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 2.1.2; + OTHER_LDFLAGS = ( + "-framework", + CoreBluetooth, + ); PRODUCT_BUNDLE_IDENTIFIER = to.bitkit; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -718,6 +732,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; @@ -745,8 +760,13 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 2.1.2; + OTHER_LDFLAGS = ( + "-framework", + CoreBluetooth, + ); PRODUCT_BUNDLE_IDENTIFIER = to.bitkit; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -937,8 +957,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { - branch = master; - kind = branch; + kind = exactVersion; + version = 0.1.48; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b1ca3574..308fcc2a 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "branch" : "master", - "revision" : "76a63a2654f717accde5268905897b73e4f7d3c4" + "revision" : "8868d60c7bc3d7077ad798f48b0b25edd6e276c4", + "version" : "0.1.48" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 6498a3b2..b25564a6 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -27,6 +27,7 @@ struct AppScene: View { @StateObject private var transferTracking: TransferTrackingManager @StateObject private var channelDetails = ChannelDetailsViewModel.shared @StateObject private var migrations = MigrationsService.shared + @State private var trezorViewModel = TrezorViewModel() @State private var hideSplash = false @State private var removeSplash = false @@ -133,6 +134,7 @@ struct AppScene: View { .environmentObject(tagManager) .environmentObject(transferTracking) .environmentObject(channelDetails) + .environment(trezorViewModel) .onAppear { if !settings.pinEnabled { isPinVerified = true diff --git a/Bitkit/Components/Trezor/TrezorDeviceRow.swift b/Bitkit/Components/Trezor/TrezorDeviceRow.swift new file mode 100644 index 00000000..1b5fec68 --- /dev/null +++ b/Bitkit/Components/Trezor/TrezorDeviceRow.swift @@ -0,0 +1,212 @@ +import BitkitCore +import SwiftUI + +/// Row displaying a discovered Trezor device +struct TrezorDeviceRow: View { + let device: TrezorDeviceInfo + let isConnecting: Bool + let onConnect: () -> Void + + var body: some View { + Button(action: { + if !isConnecting { + onConnect() + } + }) { + HStack(spacing: 16) { + // Device icon + Image(systemName: transportIcon) + .font(.system(size: 24)) + .foregroundColor(.white) + .frame(width: 48, height: 48) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + // Device info + VStack(alignment: .leading, spacing: 4) { + Text(displayName) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + + Text(transportLabel) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + } + + Spacer() + + // Connect indicator or chevron + if isConnecting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text("Connect") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.white.opacity(0.15)) + .clipShape(Capsule()) + } + } + .padding(16) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + .disabled(isConnecting) + } + + private var displayName: String { + if let label = device.label, !label.isEmpty { + return label + } + return modelName + } + + private var modelName: String { + if let model = device.model { + return "Trezor \(model)" + } + return "Trezor" + } + + private var transportIcon: String { + switch device.transportType { + case .bluetooth: + return "wave.3.right" + case .usb: + return "cable.connector" + } + } + + private var transportLabel: String { + switch device.transportType { + case .bluetooth: + return "Bluetooth" + case .usb: + return "USB" + } + } +} + +// MARK: - Known Device Row + +/// Row displaying a previously connected (known) Trezor device +struct KnownDeviceRow: View { + let device: TrezorKnownDevice + let isConnecting: Bool + let onConnect: () -> Void + let onForget: () -> Void + + var body: some View { + HStack(spacing: 16) { + // Tap area for connect + Button(action: { + if !isConnecting { + onConnect() + } + }) { + HStack(spacing: 16) { + // Device icon + Image(systemName: device.transportType == "bluetooth" ? "wave.3.right" : "cable.connector") + .font(.system(size: 24)) + .foregroundColor(.white) + .frame(width: 48, height: 48) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + // Device info + VStack(alignment: .leading, spacing: 4) { + Text(device.label ?? device.name) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + + Text(device.lastConnectedAt.relativeDescription) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.4)) + } + + Spacer() + + if isConnecting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + } + } + .buttonStyle(.plain) + .disabled(isConnecting) + + // Forget button + Button(action: onForget) { + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.4)) + .padding(10) + } + .buttonStyle(.plain) + } + .padding(16) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +// MARK: - Date Helper + +extension Date { + private static let relativeDateFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter + }() + + /// Returns a relative description like "2 minutes ago" + var relativeDescription: String { + Self.relativeDateFormatter.localizedString(for: self, relativeTo: Date()) + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorDeviceRow_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 16) { + TrezorDeviceRow( + device: TrezorDeviceInfo( + id: "ble:12345", + transportType: .bluetooth, + name: "Trezor Safe 5", + path: "ble:12345", + label: "My Trezor", + model: "Safe 5", + isBootloader: false + ), + isConnecting: false, + onConnect: {} + ) + + TrezorDeviceRow( + device: TrezorDeviceInfo( + id: "usb:001", + transportType: .usb, + name: "Trezor Model T", + path: "usb:001", + label: nil, + model: "Model T", + isBootloader: false + ), + isConnecting: true, + onConnect: {} + ) + } + .padding() + } + } + } +#endif diff --git a/Bitkit/Components/Trezor/TrezorExpandableSection.swift b/Bitkit/Components/Trezor/TrezorExpandableSection.swift new file mode 100644 index 00000000..20e64f39 --- /dev/null +++ b/Bitkit/Components/Trezor/TrezorExpandableSection.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// Reusable expandable section for the Trezor dashboard. +/// Provides a tappable header with animated expand/collapse of content. +struct TrezorExpandableSection: View { + let title: String + let icon: String + let description: String + @Binding var isExpanded: Bool + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(spacing: 0) { + // Tappable header + Button(action: { + withAnimation(.easeInOut(duration: 0.25)) { + isExpanded.toggle() + } + }) { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(.white) + .frame(width: 40, height: 40) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + + Text(description) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + } + + Spacer() + + Image(systemName: "chevron.down") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.4)) + .rotationEffect(.degrees(isExpanded ? 0 : -90)) + .animation(.easeInOut(duration: 0.25), value: isExpanded) + } + } + + // Expandable content + if isExpanded { + Divider() + .background(Color.white.opacity(0.1)) + .padding(.top, 12) + + content() + .padding(.top, 12) + .transition(.opacity) + } + } + .padding(16) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/Bitkit/Components/Trezor/TrezorPairingCodeInput.swift b/Bitkit/Components/Trezor/TrezorPairingCodeInput.swift new file mode 100644 index 00000000..5b6f4777 --- /dev/null +++ b/Bitkit/Components/Trezor/TrezorPairingCodeInput.swift @@ -0,0 +1,101 @@ +import SwiftUI + +/// 6-digit pairing code input for Trezor BLE pairing +/// User enters the code displayed on the Trezor device screen +struct TrezorPairingCodeInput: View { + /// Current pairing code being entered + @Binding var code: String + + /// Number of digits in pairing code + let digitCount: Int = 6 + + /// Focus state for keyboard + @FocusState private var isFocused: Bool + + var body: some View { + VStack(spacing: 24) { + // Code display boxes + HStack(spacing: 12) { + ForEach(0 ..< digitCount, id: \.self) { index in + CodeDigitBox( + digit: getDigit(at: index), + isActive: index == code.count && isFocused + ) + } + } + + // Hidden text field for keyboard input + TextField("", text: $code) + .keyboardType(.numberPad) + .focused($isFocused) + .frame(width: 1, height: 1) + .opacity(0.01) // Nearly invisible but still functional + .onChange(of: code) { newValue in + // Filter to only digits and limit length + let filtered = newValue.filter(\.isNumber) + if filtered.count <= digitCount { + code = filtered + } else { + code = String(filtered.prefix(digitCount)) + } + } + } + .onTapGesture { + isFocused = true + } + .task { + // Brief delay for sheet presentation animation to complete + try? await Task.sleep(nanoseconds: 200_000_000) + isFocused = true + } + } + + /// Get digit at specific index, or nil if not entered yet + private func getDigit(at index: Int) -> Character? { + guard index < code.count else { return nil } + return code[code.index(code.startIndex, offsetBy: index)] + } +} + +/// Individual digit display box +private struct CodeDigitBox: View { + let digit: Character? + let isActive: Bool + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.opacity(0.1)) + .frame(width: 48, height: 56) + + RoundedRectangle(cornerRadius: 8) + .stroke(isActive ? Color.white : Color.white.opacity(0.3), lineWidth: isActive ? 2 : 1) + .frame(width: 48, height: 56) + + if let digit { + Text(String(digit)) + .font(.system(size: 24, weight: .semibold, design: .monospaced)) + .foregroundColor(.white) + } + } + .animation(.easeInOut(duration: 0.15), value: isActive) + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorPairingCodeInput_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 40) { + TrezorPairingCodeInput(code: .constant("")) + TrezorPairingCodeInput(code: .constant("123")) + TrezorPairingCodeInput(code: .constant("123456")) + } + } + } + } +#endif diff --git a/Bitkit/Components/Trezor/TrezorPinPad.swift b/Bitkit/Components/Trezor/TrezorPinPad.swift new file mode 100644 index 00000000..de7423fd --- /dev/null +++ b/Bitkit/Components/Trezor/TrezorPinPad.swift @@ -0,0 +1,109 @@ +import SwiftUI + +/// 3x3 PIN pad for Trezor PIN entry +/// Trezor displays a scrambled keypad on device, user taps positions 1-9 in app +struct TrezorPinPad: View { + /// Current PIN being entered + @Binding var pin: String + + /// Maximum PIN length + var maxLength: Int = 9 + + // PIN pad layout (positions map to device keypad) + // The Trezor shows scrambled numbers, we show only position dots + private let positions = [ + ["7", "8", "9"], + ["4", "5", "6"], + ["1", "2", "3"], + ] + + var body: some View { + VStack(spacing: 16) { + // PIN display + HStack(spacing: 12) { + ForEach(0 ..< maxLength, id: \.self) { index in + Circle() + .fill(index < pin.count ? Color.white : Color.white.opacity(0.3)) + .frame(width: 12, height: 12) + } + } + .padding(.bottom, 24) + + // Keypad + VStack(spacing: 12) { + ForEach(positions, id: \.self) { row in + HStack(spacing: 12) { + ForEach(row, id: \.self) { position in + PinButton(position: position) { + handleDigitTap(position) + } + } + } + } + } + + // Delete button + HStack { + Spacer() + + Button(action: handleDelete) { + Image(systemName: "delete.left") + .font(.system(size: 24)) + .foregroundColor(.white) + .frame(width: 64, height: 48) + } + .disabled(pin.isEmpty) + .opacity(pin.isEmpty ? 0.3 : 1.0) + } + .padding(.top, 8) + } + .padding(16) + } + + private func handleDigitTap(_ position: String) { + guard pin.count < maxLength else { return } + pin += position + + } + + private func handleDelete() { + guard !pin.isEmpty else { return } + pin.removeLast() + + } +} + +/// Individual PIN pad button +private struct PinButton: View { + let position: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Circle() + .fill(Color.white.opacity(0.15)) + .frame(width: 72, height: 72) + .overlay( + // Show a dot instead of number (Trezor shows scrambled numbers on device) + Circle() + .fill(Color.white) + .frame(width: 16, height: 16) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorPinPad_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.black.ignoresSafeArea() + + TrezorPinPad(pin: .constant("123")) + } + } + } +#endif diff --git a/Bitkit/Components/Trezor/TrezorStatusBadge.swift b/Bitkit/Components/Trezor/TrezorStatusBadge.swift new file mode 100644 index 00000000..c928ef3f --- /dev/null +++ b/Bitkit/Components/Trezor/TrezorStatusBadge.swift @@ -0,0 +1,148 @@ +import SwiftUI + +/// Badge showing Trezor connection status +struct TrezorStatusBadge: View { + let isConnected: Bool + let deviceName: String? + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(isConnected ? Color.green : Color.orange) + .frame(width: 8, height: 8) + + Text(statusText) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + } + } + + private var statusText: String { + if isConnected { + return deviceName ?? "Connected" + } + return "Not Connected" + } +} + +/// Confirm on device overlay +struct TrezorConfirmOnDeviceOverlay: View { + let message: String + var onCancel: (() -> Void)? + + var body: some View { + VStack(spacing: 24) { + // Trezor icon + Image(systemName: "externaldrive.fill") + .font(.system(size: 48)) + .foregroundColor(.white) + .padding(24) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + + VStack(spacing: 8) { + Text("Action Required") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + + Text(message) + .font(.system(size: 16)) + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + // Pulsing indicator + PulsingDots() + .frame(height: 32) + + if let onCancel { + Button(action: onCancel) { + Text("Cancel") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + } + .padding(.top, 8) + } + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.9)) + } +} + +/// Animated pulsing dots indicator +private struct PulsingDots: View { + @State private var isAnimating = false + + var body: some View { + HStack(spacing: 8) { + ForEach(0 ..< 3) { index in + Circle() + .fill(Color.white) + .frame(width: 8, height: 8) + .scaleEffect(isAnimating ? 1.0 : 0.5) + .opacity(isAnimating ? 1.0 : 0.3) + .animation( + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.2), + value: isAnimating + ) + } + } + .onAppear { + isAnimating = true + } + } +} + +/// Reusable error banner for Trezor views. +/// NOTE: Intentionally used instead of app.toast() in the Trezor dev dashboard. +/// Inline error display provides better visibility for debugging BLE/THP protocol +/// issues during development. See CLAUDE.md for production error handling patterns. +struct TrezorErrorBanner: View { + let message: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + + Text(message) + .font(.system(size: 14)) + .foregroundColor(.white) + + Spacer() + } + .padding(16) + .background(Color.red.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorStatusBadge_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 24) { + TrezorStatusBadge(isConnected: true, deviceName: "My Trezor") + TrezorStatusBadge(isConnected: false, deviceName: nil) + } + } + } + } + + struct TrezorConfirmOnDeviceOverlay_Previews: PreviewProvider { + static var previews: some View { + TrezorConfirmOnDeviceOverlay( + message: "Confirm the address on your Trezor", + onCancel: {} + ) + } + } +#endif diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 235c8d56..220f025d 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -23,6 +23,10 @@ $(E2E_BACKEND) E2E_NETWORK $(E2E_NETWORK) + NSBluetoothAlwaysUsageDescription + Bitkit uses Bluetooth to connect to your Trezor hardware wallet for signing transactions. + NSBluetoothPeripheralUsageDescription + Bitkit uses Bluetooth to connect to your Trezor hardware wallet for signing transactions. NSFaceIDUsageDescription Bitkit uses Face ID to securely authenticate access to your wallet and protect your Bitcoin. UIAppFonts diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index ccc66b6e..2eea7cb3 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -366,15 +366,16 @@ struct MainNavView: View { case .rgsSettings: RgsSettingsScreen() case .addressViewer: AddressViewer() - // Dev settings - case .blocktankRegtest: BlocktankRegtestView() - case .ldkDebug: LdkDebugScreen() - case .vssDebug: VssDebugScreen() - case .probingTool: ProbingToolScreen() - case .orders: ChannelOrders() - case .logs: LogView() - } + // Dev settings + case .blocktankRegtest: BlocktankRegtestView() + case .ldkDebug: LdkDebugScreen() + case .vssDebug: VssDebugScreen() + case .probingTool: ProbingToolScreen() + case .orders: ChannelOrders() + case .logs: LogView() + case .trezor: TrezorRootView() } + } } private func handleClipboard() { diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index d871d109..8347e001 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -3,6 +3,22 @@ import Combine import Foundation import LDKNode +// MARK: - Local Types (removed from BitkitCore in Trezor module rewrite) + +/// Address info with usage data +struct AddressInfo { + let address: String + let path: String + let transfers: UInt32 +} + +/// Grouped account addresses by usage +struct AccountAddresses { + let used: [AddressInfo] + let unused: [AddressInfo] + let change: [AddressInfo] +} + // MARK: - Activity Service class ActivityService { @@ -1799,25 +1815,25 @@ class UtilityService { ) // Convert GetAddressesResponse to AccountAddresses - let usedAddresses = response.addresses.compactMap { addr -> BitkitCore.AddressInfo? in + let usedAddresses = response.addresses.compactMap { addr -> AddressInfo? in // You would determine if an address is used based on your logic // For now, we'll create a basic conversion - return BitkitCore.AddressInfo( + return AddressInfo( address: addr.address, path: addr.path, transfers: 0 // This would need to be determined from blockchain data ) } - let unusedAddresses = response.addresses.compactMap { addr -> BitkitCore.AddressInfo? in - return BitkitCore.AddressInfo( + let unusedAddresses = response.addresses.compactMap { addr -> AddressInfo? in + return AddressInfo( address: addr.address, path: addr.path, transfers: 0 ) } - let changeAddresses: [BitkitCore.AddressInfo] = [] + let changeAddresses: [AddressInfo] = [] return AccountAddresses( used: usedAddresses, diff --git a/Bitkit/Services/Trezor/TrezorBLEManager.swift b/Bitkit/Services/Trezor/TrezorBLEManager.swift new file mode 100644 index 00000000..91f30952 --- /dev/null +++ b/Bitkit/Services/Trezor/TrezorBLEManager.swift @@ -0,0 +1,761 @@ +import CoreBluetooth +import Foundation +import Observation + +/// Manages CoreBluetooth operations for Trezor BLE devices +/// Handles device discovery, connection, and GATT communication +@Observable +class TrezorBLEManager: NSObject { + static let shared = TrezorBLEManager() + + // MARK: - BLE Constants + + /// Trezor BLE Service UUID + static let serviceUUID = CBUUID(string: "8c000001-a59b-4d58-a9ad-073df69fa1b1") + /// Write Characteristic UUID (for sending data to device) + static let writeCharUUID = CBUUID(string: "8c000002-a59b-4d58-a9ad-073df69fa1b1") + /// Notify Characteristic UUID (for receiving data from device) + static let notifyCharUUID = CBUUID(string: "8c000003-a59b-4d58-a9ad-073df69fa1b1") + /// Client Characteristic Configuration Descriptor UUID (for enabling notifications) + static let cccdUUID = CBUUID(string: "00002902-0000-1000-8000-00805f9b34fb") + /// BLE chunk size for Trezor THP protocol + static let chunkSize: UInt32 = 244 + + // MARK: - Timeouts + + private static let connectionTimeoutSeconds: TimeInterval = 30 + private static let discoveryTimeoutSeconds: TimeInterval = 10 + private static let readTimeoutSeconds: TimeInterval = 5 + private static let writeTimeoutSeconds: TimeInterval = 5 + /// Number of write retry attempts (matching Android's BLE_WRITE_RETRY_COUNT) + private static let writeMaxAttempts = 3 + /// Delay between write retry attempts (matching Android's BLE_WRITE_RETRY_DELAY_MS) + private static let writeRetryDelayNs: UInt64 = 100_000_000 + /// Inter-write delay for BLE stability (20ms between writes) + private static let writeInterDelayNs: UInt64 = 20_000_000 + + // MARK: - Properties + + private var centralManager: CBCentralManager? + private let centralQueue = DispatchQueue(label: "trezor.ble.central", qos: .userInteractive) + + /// Discovered peripherals keyed by device path (ble:{identifier}) + private var discoveredPeripherals: [String: CBPeripheral] = [:] + private let peripheralsLock = NSLock() + + /// Currently connected peripheral + private var connectedPeripheral: CBPeripheral? + private var connectedPath: String? + + /// GATT characteristics + private var writeCharacteristic: CBCharacteristic? + private var notifyCharacteristic: CBCharacteristic? + + /// Queue for received notification data chunks + private var readQueue = BlockingQueue() + + /// Continuation for connection completion + private var connectContinuation: CheckedContinuation? + private var connectionError: Error? + + /// Continuation for write completion + private var writeContinuation: CheckedContinuation? + + /// Continuation for service discovery + private var serviceDiscoveryContinuation: CheckedContinuation? + + /// Lock protecting all continuation properties from concurrent access. + /// Timeouts fire on DispatchQueue.global() while delegate callbacks fire on + /// centralQueue — without synchronization both can resume the same continuation. + private let continuationLock = NSLock() + + // MARK: - Continuation Helpers + + /// Atomically take and nil-out a continuation so it can only be resumed once. + private func takeConnectContinuation() -> CheckedContinuation? { + continuationLock.lock() + defer { continuationLock.unlock() } + let cont = connectContinuation + connectContinuation = nil + return cont + } + + private func takeWriteContinuation() -> CheckedContinuation? { + continuationLock.lock() + defer { continuationLock.unlock() } + let cont = writeContinuation + writeContinuation = nil + return cont + } + + private func takeServiceDiscoveryContinuation() -> CheckedContinuation? { + continuationLock.lock() + defer { continuationLock.unlock() } + let cont = serviceDiscoveryContinuation + serviceDiscoveryContinuation = nil + return cont + } + + private func takeNotificationContinuation() -> CheckedContinuation? { + continuationLock.lock() + defer { continuationLock.unlock() } + let cont = notificationContinuation + notificationContinuation = nil + return cont + } + + /// Atomically store a continuation. + private func setConnectContinuation(_ cont: CheckedContinuation) { + continuationLock.lock() + connectContinuation = cont + continuationLock.unlock() + } + + private func setWriteContinuation(_ cont: CheckedContinuation) { + continuationLock.lock() + writeContinuation = cont + continuationLock.unlock() + } + + private func setServiceDiscoveryContinuation(_ cont: CheckedContinuation) { + continuationLock.lock() + serviceDiscoveryContinuation = cont + continuationLock.unlock() + } + + private func setNotificationContinuation(_ cont: CheckedContinuation) { + continuationLock.lock() + notificationContinuation = cont + continuationLock.unlock() + } + + // MARK: - Observable State + + private(set) var discoveredDevices: [DiscoveredBLEDevice] = [] + private(set) var isScanning: Bool = false + private(set) var bluetoothState: CBManagerState = .unknown + + // MARK: - Initialization + + private override init() { + super.init() + // CBCentralManager is created lazily via ensureStarted() to avoid + // triggering the BLE stack and permission dialogs at app launch. + } + + /// Create CBCentralManager on first use. Must be called before any BLE operation. + func ensureStarted() { + guard centralManager == nil else { return } + centralManager = CBCentralManager(delegate: self, queue: centralQueue) + } + + // MARK: - Debug Logging + + /// Log to both Logger and in-app TrezorDebugLog + private func debugLog(_ message: String) { + Logger.debug(message, context: "TrezorBLEManager") + Task { @MainActor in + TrezorDebugLog.shared.log("[BLE] \(message)") + } + } + + // MARK: - Scanning + + /// Start scanning for Trezor BLE devices + func startScanning() { + ensureStarted() + + guard centralManager?.state == .poweredOn else { + debugLog("Cannot scan: BT not powered on (state: \(centralManager?.state.rawValue ?? -1))") + return + } + + guard !isScanning else { + debugLog("Already scanning") + return + } + + debugLog("Scan started") + + peripheralsLock.lock() + discoveredPeripherals.removeAll() + peripheralsLock.unlock() + + Task { @MainActor in + self.discoveredDevices.removeAll() + self.isScanning = true + } + + centralManager?.scanForPeripherals( + withServices: [Self.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } + + /// Stop scanning for devices + func stopScanning() { + guard isScanning else { return } + + debugLog("Scan stopped") + centralManager?.stopScan() + + Task { @MainActor in + self.isScanning = false + } + } + + /// Get all discovered devices as NativeDeviceInfo for FFI + func enumerateDevices() -> [DiscoveredBLEDevice] { + peripheralsLock.lock() + defer { peripheralsLock.unlock() } + return discoveredDevices + } + + // MARK: - Connection + + /// Continuation for notification state change + private var notificationContinuation: CheckedContinuation? + + // MARK: - Connection Retry Configuration + + private static let connectMaxAttempts = 3 + + /// Connect to a BLE device by path with automatic retry + /// After force-quit, the Trezor may drop the first connection while cleaning up + /// stale state from the previous app session. Retrying after a delay allows the + /// Trezor to finish cleanup and accept a stable connection. + /// - Parameter path: Device path in format "ble:{identifier}" + func connect(path: String) async throws { + guard path.hasPrefix("ble:") else { + throw TrezorBLEError.invalidPath(path) + } + + var lastError: Error = TrezorBLEError.connectionFailed + + for attempt in 1 ... Self.connectMaxAttempts { + do { + debugLog("Connect attempt \(attempt)/\(Self.connectMaxAttempts) for \(path)") + try await connectOnce(path: path) + debugLog("Connect attempt \(attempt) succeeded") + return // Success + } catch { + lastError = error + debugLog("Connect attempt \(attempt)/\(Self.connectMaxAttempts) failed: \(error.localizedDescription)") + + // Don't retry "Peer removed pairing information" — requires user action + // (forget device in iOS Settings → Bluetooth) + if let cbError = error as? CBError, cbError.code == .peerRemovedPairingInformation { + debugLog("Stale iOS bonding detected — user must forget device in Settings") + throw TrezorBLEError.pairingInformationRemoved + } + + // Clean up before retry + cleanupConnectionState() + + if attempt < Self.connectMaxAttempts { + // Increasing delay: 1s, 2s — gives Trezor time to clean up stale state + let delaySeconds = UInt64(attempt) + try await Task.sleep(nanoseconds: delaySeconds * 1_000_000_000) + } + } + } + + throw lastError + } + + /// Clean up connection state without clearing the discoveredPeripherals cache + /// This preserves the freshly-scanned peripheral reference for reconnection + private func cleanupConnectionState() { + if let existingPeripheral = connectedPeripheral { + if let notifyChar = notifyCharacteristic { + existingPeripheral.setNotifyValue(false, for: notifyChar) + } + centralManager?.cancelPeripheralConnection(existingPeripheral) + } + connectedPeripheral = nil + connectedPath = nil + writeCharacteristic = nil + notifyCharacteristic = nil + readQueue.clear() + } + + /// Single connection attempt — performs the full BLE connection sequence + private func connectOnce(path: String) async throws { + // Clean up any stale connection state (preserves discoveredPeripherals cache) + cleanupConnectionState() + + debugLog("connectOnce: \(path)") + + // Try to get peripheral from cache first + var peripheral: CBPeripheral? + + peripheralsLock.lock() + peripheral = discoveredPeripherals[path] + peripheralsLock.unlock() + + // If not in cache, try retrieving by UUID (works for previously bonded devices) + if peripheral == nil { + let uuidString = path.replacingOccurrences(of: "ble:", with: "") + if let uuid = UUID(uuidString: uuidString) { + let retrieved = centralManager?.retrievePeripherals(withIdentifiers: [uuid]) ?? [] + peripheral = retrieved.first + if let p = peripheral { + peripheralsLock.lock() + discoveredPeripherals[path] = p + peripheralsLock.unlock() + debugLog("Retrieved peripheral by UUID: \(path)") + } + } + } + + guard let peripheral = peripheral else { + debugLog("Peripheral not found in cache or by UUID: \(path)") + throw TrezorBLEError.deviceNotFound(path) + } + + debugLog("Peripheral found: \(peripheral.identifier) state=\(peripheral.state.rawValue)") + + // Handle stale or transitioning OS-level connection from previous app session + // After force-quit, iOS may keep the BLE connection alive briefly. Connecting + // to an already-connected peripheral can produce a stale GATT session that + // the Trezor then drops. Cancel it first and wait for disconnect to complete. + if peripheral.state == .connected || peripheral.state == .connecting { + debugLog("Cancelling stale connection (state: \(peripheral.state.rawValue))") + centralManager?.cancelPeripheralConnection(peripheral) + try await Task.sleep(nanoseconds: 500_000_000) // 500ms for disconnect to complete + } + + // Step 1: Connect to peripheral + debugLog("CBConnect starting...") + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.setConnectContinuation(continuation) + self.connectionError = nil + + centralManager?.connect(peripheral, options: nil) + + // Set up timeout + DispatchQueue.global().asyncAfter(deadline: .now() + Self.connectionTimeoutSeconds) { [weak self] in + self?.takeConnectContinuation()?.resume(throwing: TrezorBLEError.connectionTimeout) + } + } + + connectedPeripheral = peripheral + connectedPath = path + peripheral.delegate = self + + // Step 2: Get MTU (synchronous on iOS — no delegate callback needed) + let mtu = peripheral.maximumWriteValueLength(for: .withResponse) + debugLog("MTU: \(mtu) bytes") + + // Step 3: Discover services and characteristics + debugLog("Discovering services...") + try await discoverServicesAndCharacteristics(peripheral: peripheral) + + // Step 4: Enable notifications and wait for confirmation + guard let notifyChar = notifyCharacteristic else { + throw TrezorBLEError.characteristicNotFound("notify") + } + + debugLog("Enabling notifications...") + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.setNotificationContinuation(continuation) + peripheral.setNotifyValue(true, for: notifyChar) + + // Timeout for notification enable + DispatchQueue.global().asyncAfter(deadline: .now() + 5.0) { [weak self] in + // Don't fail, just continue + self?.takeNotificationContinuation()?.resume() + } + } + + // Step 5: Stabilization delay (like Android's BLE_CONNECTION_STABILIZATION_MS) + // Keep this short (500ms) to avoid delaying the THP handshake start — + // the Trezor may timeout if the handshake doesn't begin promptly. + debugLog("Stabilization (500ms)...") + try await Task.sleep(nanoseconds: 500_000_000) // 500ms + + guard connectedPeripheral != nil, connectedPeripheral?.state == .connected else { + debugLog("Connection LOST during stabilization") + throw TrezorBLEError.connectionFailed + } + + debugLog("Connection READY: \(path)") + } + + private func discoverServicesAndCharacteristics(peripheral: CBPeripheral) async throws { + // Discover Trezor service + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.setServiceDiscoveryContinuation(continuation) + peripheral.discoverServices([Self.serviceUUID]) + + // Timeout for service discovery + DispatchQueue.global().asyncAfter(deadline: .now() + Self.discoveryTimeoutSeconds) { [weak self] in + self?.takeServiceDiscoveryContinuation()?.resume(throwing: TrezorBLEError.serviceNotFound) + } + } + + // Get the Trezor service + guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else { + throw TrezorBLEError.serviceNotFound + } + + // Discover characteristics + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.setServiceDiscoveryContinuation(continuation) + peripheral.discoverCharacteristics([Self.writeCharUUID, Self.notifyCharUUID], for: service) + + // Timeout for characteristic discovery + DispatchQueue.global().asyncAfter(deadline: .now() + Self.discoveryTimeoutSeconds) { [weak self] in + self?.takeServiceDiscoveryContinuation()?.resume(throwing: TrezorBLEError.characteristicNotFound("discovery timeout")) + } + } + + // Get characteristics + guard let characteristics = service.characteristics else { + throw TrezorBLEError.characteristicNotFound("any") + } + + writeCharacteristic = characteristics.first(where: { $0.uuid == Self.writeCharUUID }) + notifyCharacteristic = characteristics.first(where: { $0.uuid == Self.notifyCharUUID }) + + guard writeCharacteristic != nil else { + throw TrezorBLEError.characteristicNotFound("write") + } + guard notifyCharacteristic != nil else { + throw TrezorBLEError.characteristicNotFound("notify") + } + + debugLog("Found write and notify characteristics") + } + + /// Disconnect from currently connected device + func disconnect(path: String) { + guard path == connectedPath, let peripheral = connectedPeripheral else { + debugLog("Disconnect ignored (not connected): \(path)") + return + } + + debugLog("Disconnecting: \(path)") + + if let notifyChar = notifyCharacteristic { + peripheral.setNotifyValue(false, for: notifyChar) + } + + centralManager?.cancelPeripheralConnection(peripheral) + + connectedPeripheral = nil + connectedPath = nil + writeCharacteristic = nil + notifyCharacteristic = nil + readQueue.clear() + } + + // MARK: - Read/Write Operations + + /// Read a chunk from the device (blocks until data received or timeout) + /// - Parameter path: Device path + /// - Returns: Data chunk read from device + func readChunk(path: String) throws -> Data { + guard path == connectedPath else { + debugLog("readChunk: not connected") + throw TrezorBLEError.notConnected + } + + // Block waiting for notification data + guard let data = readQueue.poll(timeout: Self.readTimeoutSeconds) else { + debugLog("readChunk: TIMEOUT") + throw TrezorBLEError.readTimeout + } + + debugLog("readChunk: \(data.count) bytes") + return data + } + + /// Write a chunk to the device with retry logic. + /// + /// BLE writes use `.withResponse`, so a timeout means the data was NOT + /// delivered to the device (the GATT stack guarantees delivery confirmation). + /// Retrying is therefore safe — the device never received the previous attempt. + /// This matches Android's `BLE_WRITE_RETRY_COUNT = 3` pattern. + /// + /// - Parameters: + /// - path: Device path + /// - data: Data chunk to write + func writeChunk(path: String, data: Data) async throws { + guard path == connectedPath, let peripheral = connectedPeripheral else { + debugLog("writeChunk: not connected") + throw TrezorBLEError.notConnected + } + + // Validate connection state (prevents writes to stale peripherals) + guard peripheral.state == .connected else { + debugLog("writeChunk: peripheral state=\(peripheral.state.rawValue)") + throw TrezorBLEError.notConnected + } + + guard let writeChar = writeCharacteristic else { + throw TrezorBLEError.characteristicNotFound("write") + } + + var lastError: Error = TrezorBLEError.writeTimeout + + for attempt in 1 ... Self.writeMaxAttempts { + do { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.setWriteContinuation(continuation) + + peripheral.writeValue(data, for: writeChar, type: .withResponse) + + // Set up timeout + DispatchQueue.global().asyncAfter(deadline: .now() + Self.writeTimeoutSeconds) { [weak self] in + self?.takeWriteContinuation()?.resume(throwing: TrezorBLEError.writeTimeout) + } + } + + // Success — apply inter-write delay for BLE stability + try await Task.sleep(nanoseconds: Self.writeInterDelayNs) + return + } catch { + lastError = error + debugLog("writeChunk attempt \(attempt)/\(Self.writeMaxAttempts) failed: \(error.localizedDescription)") + + // Only retry timeout errors — non-timeout errors (e.g. disconnection) + // indicate an unrecoverable state + guard case TrezorBLEError.writeTimeout = error else { + throw error + } + + if attempt < Self.writeMaxAttempts { + try await Task.sleep(nanoseconds: Self.writeRetryDelayNs) + } + } + } + + throw lastError + } + +} + +// MARK: - CBCentralManagerDelegate + +extension TrezorBLEManager: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + debugLog("BT state: \(central.state.rawValue)") + + Task { @MainActor in + self.bluetoothState = central.state + } + + if central.state != .poweredOn && isScanning { + Task { @MainActor in + self.isScanning = false + } + } + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) + { + let path = "ble:\(peripheral.identifier.uuidString)" + let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String + + debugLog("didDiscover: \(name ?? "Unknown") \(path) RSSI=\(RSSI)") + + peripheralsLock.lock() + discoveredPeripherals[path] = peripheral + + let device = DiscoveredBLEDevice( + path: path, + name: name, + identifier: peripheral.identifier + ) + + // Update on main thread + Task { @MainActor in + if let index = self.discoveredDevices.firstIndex(where: { $0.path == path }) { + self.discoveredDevices[index] = device + } else { + self.discoveredDevices.append(device) + } + } + peripheralsLock.unlock() + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + debugLog("didConnect: \(peripheral.identifier)") + + takeConnectContinuation()?.resume() + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + debugLog("didFailToConnect: \(peripheral.identifier) error: \(error?.localizedDescription ?? "Unknown")") + + takeConnectContinuation()?.resume(throwing: error ?? TrezorBLEError.connectionFailed) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + debugLog("didDisconnect: \(peripheral.identifier)\(error.map { " error: \($0.localizedDescription)" } ?? "")") + + let disconnectError = error ?? TrezorBLEError.connectionFailed + + // Resume any pending continuations so they fail-fast instead of hanging + takeConnectContinuation()?.resume(throwing: disconnectError) + takeServiceDiscoveryContinuation()?.resume(throwing: disconnectError) + takeNotificationContinuation()?.resume(throwing: disconnectError) + takeWriteContinuation()?.resume(throwing: disconnectError) + + if connectedPeripheral?.identifier == peripheral.identifier { + connectedPeripheral = nil + connectedPath = nil + writeCharacteristic = nil + notifyCharacteristic = nil + readQueue.clear() + } + } +} + +// MARK: - CBPeripheralDelegate + +extension TrezorBLEManager: CBPeripheralDelegate { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + debugLog("didDiscoverServices FAILED: \(error.localizedDescription)") + takeServiceDiscoveryContinuation()?.resume(throwing: error) + } else { + debugLog("didDiscoverServices: \(peripheral.services?.count ?? 0) services") + takeServiceDiscoveryContinuation()?.resume() + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error { + debugLog("didDiscoverCharacteristics FAILED: \(error.localizedDescription)") + takeServiceDiscoveryContinuation()?.resume(throwing: error) + } else { + debugLog("didDiscoverCharacteristics: \(service.characteristics?.count ?? 0) chars") + takeServiceDiscoveryContinuation()?.resume() + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if let error { + debugLog("didUpdateValue ERROR: \(error.localizedDescription)") + return + } + + guard characteristic.uuid == Self.notifyCharUUID, let data = characteristic.value else { + return + } + + debugLog("didUpdateValue: \(data.count) bytes") + readQueue.offer(data) + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error { + debugLog("didWriteValue FAILED: \(error.localizedDescription)") + takeWriteContinuation()?.resume(throwing: error) + } else { + takeWriteContinuation()?.resume() + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error { + debugLog("didUpdateNotificationState FAILED: \(error.localizedDescription)") + takeNotificationContinuation()?.resume(throwing: error) + } else { + debugLog("didUpdateNotificationState: \(characteristic.uuid) isNotifying=\(characteristic.isNotifying)") + takeNotificationContinuation()?.resume() + } + } +} + +// MARK: - Supporting Types + +/// Represents a discovered BLE Trezor device +struct DiscoveredBLEDevice: Identifiable, Equatable { + let path: String + let name: String? + let identifier: UUID + + var id: String { path } +} + +/// Errors specific to BLE operations +enum TrezorBLEError: LocalizedError { + case invalidPath(String) + case deviceNotFound(String) + case connectionFailed + case connectionTimeout + case notConnected + case serviceNotFound + case characteristicNotFound(String) + case pairingInformationRemoved + case readTimeout + case writeTimeout + case writeFailed + + var errorDescription: String? { + switch self { + case let .invalidPath(path): + return "Invalid BLE device path: \(path)" + case let .deviceNotFound(path): + return "Device not found: \(path)" + case .connectionFailed: + return "Failed to connect to Trezor" + case .connectionTimeout: + return "Connection to Trezor timed out" + case .notConnected: + return "Not connected to Trezor" + case .pairingInformationRemoved: + return "Stale Bluetooth pairing. Go to iOS Settings → Bluetooth, forget your Trezor device, then put it back in pairing mode and try again." + case .serviceNotFound: + return "Trezor BLE service not found" + case let .characteristicNotFound(name): + return "Trezor BLE characteristic not found: \(name)" + case .readTimeout: + return "Timed out waiting for Trezor response" + case .writeTimeout: + return "Timed out sending data to Trezor" + case .writeFailed: + return "Failed to send data to Trezor" + } + } +} + +// MARK: - BlockingQueue + +/// Thread-safe blocking queue for BLE notification data +private class BlockingQueue { + private var queue: [T] = [] + private let lock = NSCondition() + + func offer(_ item: T) { + lock.lock() + queue.append(item) + lock.signal() + lock.unlock() + } + + func poll(timeout: TimeInterval) -> T? { + lock.lock() + defer { lock.unlock() } + + let deadline = Date().addingTimeInterval(timeout) + + while queue.isEmpty { + if !lock.wait(until: deadline) { + return nil // Timeout + } + } + + return queue.removeFirst() + } + + func clear() { + lock.lock() + queue.removeAll() + lock.unlock() + } +} diff --git a/Bitkit/Services/Trezor/TrezorCredentialStorage.swift b/Bitkit/Services/Trezor/TrezorCredentialStorage.swift new file mode 100644 index 00000000..88993193 --- /dev/null +++ b/Bitkit/Services/Trezor/TrezorCredentialStorage.swift @@ -0,0 +1,131 @@ +import Foundation +import Security + +/// Keychain storage for Trezor THP (Trezor Host Protocol) credentials +/// These credentials allow reconnection to BLE devices without re-pairing +enum TrezorCredentialStorage { + // MARK: - Keychain Configuration + + /// Keychain service identifier for Trezor credentials + private static let serviceName = "to.bitkit.trezor.thp" + + // MARK: - Public API + + /// Save THP credential for a device + /// - Parameters: + /// - deviceId: Device identifier (MAC address or UUID) + /// - json: JSON string containing credential data + /// - Returns: True if save was successful + static func save(deviceId: String, json: String) -> Bool { + let key = sanitizeDeviceId(deviceId) + + guard let data = json.data(using: .utf8) else { + Logger.error("Failed to convert credential to data", context: "TrezorCredentialStorage") + return false + } + + // Delete existing credential first + delete(deviceId: deviceId) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecAttrSynchronizable as String: false, + kSecValueData as String: data, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + + if status == errSecSuccess { + Logger.info("Saved THP credential for device: \(key)", context: "TrezorCredentialStorage") + return true + } else { + Logger.error("Failed to save THP credential: \(status)", context: "TrezorCredentialStorage") + return false + } + } + + /// Load THP credential for a device + /// - Parameter deviceId: Device identifier + /// - Returns: JSON string containing credential data, or nil if not found + static func load(deviceId: String) -> String? { + let key = sanitizeDeviceId(deviceId) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var dataRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataRef) + + if status == errSecSuccess, let data = dataRef as? Data { + if let json = String(data: data, encoding: .utf8) { + Logger.debug("Loaded THP credential for device: \(key)", context: "TrezorCredentialStorage") + return json + } + } else if status == errSecItemNotFound { + Logger.debug("No THP credential found for device: \(key)", context: "TrezorCredentialStorage") + } else { + Logger.error("Failed to load THP credential: \(status)", context: "TrezorCredentialStorage") + } + + return nil + } + + /// Delete THP credential for a device + /// - Parameter deviceId: Device identifier + static func delete(deviceId: String) { + let key = sanitizeDeviceId(deviceId) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + ] + + let status = SecItemDelete(query as CFDictionary) + + if status == errSecSuccess { + Logger.info("Deleted THP credential for device: \(key)", context: "TrezorCredentialStorage") + } else if status != errSecItemNotFound { + Logger.warn("Failed to delete THP credential: \(status)", context: "TrezorCredentialStorage") + } + } + + /// List all device IDs with stored credentials + /// - Returns: Array of device IDs (sanitized form) + static func listAllDeviceIds() -> [String] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let items = result as? [[String: Any]] { + return items.compactMap { $0[kSecAttrAccount as String] as? String } + } + + return [] + } + + // MARK: - Private Helpers + + /// Sanitize device ID for use as keychain account key + /// Replaces characters that may cause issues in keychain + private static func sanitizeDeviceId(_ deviceId: String) -> String { + return deviceId + .replacingOccurrences(of: ":", with: "_") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: " ", with: "_") + } +} diff --git a/Bitkit/Services/Trezor/TrezorDebugLog.swift b/Bitkit/Services/Trezor/TrezorDebugLog.swift new file mode 100644 index 00000000..afdd9406 --- /dev/null +++ b/Bitkit/Services/Trezor/TrezorDebugLog.swift @@ -0,0 +1,86 @@ +import Foundation +import Observation + +/// Singleton debug log for Trezor operations +/// Provides in-app timestamped logging for debugging BLE/THP issues +/// +/// Uses a buffered approach: messages are accumulated off-main and +/// flushed to the observed `entries` property at a throttled interval to +/// avoid overwhelming SwiftUI during high-volume FFI callbacks. +@Observable +@MainActor +class TrezorDebugLog { + static let shared = TrezorDebugLog() + + /// Observed entries — updated at most every `flushInterval` + var entries: [String] = [] + + private static let maxEntries = 300 + + /// Minimum interval between flushes to @Published + private static let flushInterval: TimeInterval = 0.25 + + nonisolated(unsafe) private let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f + }() + + /// Thread-safe buffer for incoming log messages + nonisolated(unsafe) private let bufferLock = NSLock() + nonisolated(unsafe) private var buffer: [String] = [] + nonisolated(unsafe) private var flushScheduled = false + + private init() {} + + /// Add a timestamped log entry. + /// Can be called from any thread — messages are buffered and + /// flushed to @Published on main at a throttled rate. + nonisolated func log(_ message: String) { + let timestamp = formatter.string(from: Date()) + let entry = "[\(timestamp)] \(message)" + + bufferLock.lock() + buffer.append(entry) + let needsSchedule = !flushScheduled + if needsSchedule { + flushScheduled = true + } + bufferLock.unlock() + + if needsSchedule { + DispatchQueue.main.asyncAfter(deadline: .now() + Self.flushInterval) { + self.flushBuffer() + } + } + } + + /// Flush buffered entries into the @Published array + private func flushBuffer() { + bufferLock.lock() + let pending = buffer + buffer.removeAll(keepingCapacity: true) + flushScheduled = false + bufferLock.unlock() + + guard !pending.isEmpty else { return } + + entries.append(contentsOf: pending) + if entries.count > Self.maxEntries { + entries.removeFirst(entries.count - Self.maxEntries) + } + } + + /// Clear all log entries + func clear() { + bufferLock.lock() + buffer.removeAll() + bufferLock.unlock() + entries.removeAll() + } + + /// Copy all entries as a single string (chronological) + func copyAll() -> String { + entries.joined(separator: "\n") + } +} diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift new file mode 100644 index 00000000..9b747b62 --- /dev/null +++ b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Represents a previously connected Trezor device +struct TrezorKnownDevice: Codable, Identifiable { + let id: String + let name: String + let path: String + let transportType: String + var label: String? + var model: String? + var lastConnectedAt: Date +} + +/// Persists known Trezor device metadata in UserDefaults +/// THP credentials remain in Keychain via TrezorCredentialStorage +enum TrezorKnownDeviceStorage { + private static let key = "trezor.knownDevices" + + /// Load all known devices, sorted by most recently connected + static func loadAll() -> [TrezorKnownDevice] { + guard let data = UserDefaults.standard.data(forKey: key) else { return [] } + let devices = (try? JSONDecoder().decode([TrezorKnownDevice].self, from: data)) ?? [] + return devices.sorted { $0.lastConnectedAt > $1.lastConnectedAt } + } + + /// Save or update a known device + static func save(_ device: TrezorKnownDevice) { + var devices = loadAll() + devices.removeAll { $0.id == device.id } + devices.insert(device, at: 0) + 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() + devices.removeAll { $0.id == id } + if let data = try? JSONEncoder().encode(devices) { + UserDefaults.standard.set(data, forKey: key) + } + } + + /// Check if a device is known + static func isKnown(id: String) -> Bool { + loadAll().contains { $0.id == id } + } +} diff --git a/Bitkit/Services/Trezor/TrezorService.swift b/Bitkit/Services/Trezor/TrezorService.swift new file mode 100644 index 00000000..2199b23d --- /dev/null +++ b/Bitkit/Services/Trezor/TrezorService.swift @@ -0,0 +1,252 @@ +import BitkitCore +import Foundation + +/// Service layer wrapper for Trezor FFI functions +/// All operations run on ServiceQueue.background(.core) to ensure thread safety +class TrezorService { + static let shared = TrezorService() + + private var callbacksRegistered = false + private let callbackLock = NSLock() + + private init() {} + + // MARK: - Callback Registration + + /// Ensures transport callback is registered before any Trezor operations + private func ensureCallbacksRegistered() { + callbackLock.lock() + defer { callbackLock.unlock() } + + guard !callbacksRegistered else { return } + + trezorSetTransportCallback(callback: TrezorTransport.shared) + trezorSetUiCallback(callback: TrezorUiHandler.shared) + callbacksRegistered = true + + Logger.info("Trezor callbacks registered", context: "TrezorService") + } + + // MARK: - Initialization + + /// Initialize the Trezor manager + /// - Parameter credentialPath: Optional path for credential storage (nil uses app default) + func initialize(credentialPath: String? = nil) async throws { + try await ServiceQueue.background(.core) { [self] in + ensureCallbacksRegistered() + try await trezorInitialize(credentialPath: credentialPath) + } + Logger.info("Trezor manager initialized", context: "TrezorService") + } + + /// Check if the Trezor manager is initialized + func isInitialized() async -> Bool { + await trezorIsInitialized() + } + + // MARK: - Device Discovery + + /// Scan for available Trezor devices (USB + Bluetooth) + /// - Returns: Array of discovered devices + func scan() async throws -> [TrezorDeviceInfo] { + try await ServiceQueue.background(.core) { [self] in + ensureCallbacksRegistered() + return try await trezorScan() + } + } + + /// List previously discovered devices + /// - Returns: Array of known devices + func listDevices() async throws -> [TrezorDeviceInfo] { + try await ServiceQueue.background(.core) { + try await trezorListDevices() + } + } + + // MARK: - Connection Management + + /// Connect to a Trezor device by its ID + /// - Parameter deviceId: The device identifier (path) + /// - Returns: Device features after successful connection + func connect(deviceId: String) async throws -> TrezorFeatures { + try await ServiceQueue.background(.core) { [self] in + ensureCallbacksRegistered() + return try await trezorConnect(deviceId: deviceId) + } + } + + /// Disconnect from the currently connected device + func disconnect() async throws { + try await ServiceQueue.background(.core) { + try await trezorDisconnect() + } + Logger.info("Disconnected from Trezor", context: "TrezorService") + } + + /// Check if a device is currently connected + func isConnected() async -> Bool { + await trezorIsConnected() + } + + /// Get information about the currently connected device + func getConnectedDevice() async -> TrezorDeviceInfo? { + await trezorGetConnectedDevice() + } + + /// Get cached features of the currently connected device + func getFeatures() async -> TrezorFeatures? { + await trezorGetFeatures() + } + + /// Get the device's master root fingerprint as an 8-character hex string + /// - Returns: The root fingerprint (e.g., "73c5da0a") + func getDeviceFingerprint() async throws -> String { + try await ServiceQueue.background(.core) { + try await trezorGetDeviceFingerprint() + } + } + + // MARK: - Address Operations + + /// Get a Bitcoin address from the connected device + /// - Parameter params: Address derivation parameters + /// - Returns: The generated address response + func getAddress(params: TrezorGetAddressParams) async throws -> TrezorAddressResponse { + try await ServiceQueue.background(.core) { + try await trezorGetAddress(params: params) + } + } + + /// Get a public key (xpub) from the connected device + /// - Parameter params: Public key derivation parameters + /// - Returns: The public key response + func getPublicKey(params: TrezorGetPublicKeyParams) async throws -> TrezorPublicKeyResponse { + try await ServiceQueue.background(.core) { + try await trezorGetPublicKey(params: params) + } + } + + // MARK: - Transaction Signing + + /// Sign a Bitcoin transaction with the connected device + /// - Parameter params: Transaction signing parameters + /// - Returns: The signed transaction + func signTx(params: TrezorSignTxParams) async throws -> TrezorSignedTx { + try await ServiceQueue.background(.core) { + try await trezorSignTx(params: params) + } + } + + /// Sign a Bitcoin transaction from a PSBT (base64-encoded) + /// - Parameters: + /// - psbtBase64: Base64-encoded PSBT data + /// - network: Bitcoin network type. Defaults to Bitcoin (mainnet) if nil. + /// - Returns: The signed transaction + func signTxFromPsbt(psbtBase64: String, network: TrezorCoinType? = nil) async throws -> TrezorSignedTx { + try await ServiceQueue.background(.core) { + try await trezorSignTxFromPsbt(psbtBase64: psbtBase64, network: network) + } + } + + // MARK: - Message Signing + + /// Sign a message with the connected device + /// - Parameter params: Message signing parameters + /// - Returns: The signed message response + func signMessage(params: TrezorSignMessageParams) async throws -> TrezorSignedMessageResponse { + try await ServiceQueue.background(.core) { + try await trezorSignMessage(params: params) + } + } + + /// Verify a message signature with the connected device + /// - Parameter params: Message verification parameters + /// - Returns: True if the signature is valid + func verifyMessage(params: TrezorVerifyMessageParams) async throws -> Bool { + try await ServiceQueue.background(.core) { + try await trezorVerifyMessage(params: params) + } + } + + // MARK: - Account/Address Info (No Device Required) + + /// Get account info (balance, UTXOs) for an extended public key (xpub/ypub/zpub/tpub/upub/vpub). + /// This does NOT require a connected Trezor device — it queries the Electrum server directly. + func getAccountInfo( + extendedKey: String, + electrumUrl: String, + network: TrezorCoinType? = nil, + gapLimit: UInt32? = nil, + scriptType: AccountType? = nil + ) async throws -> AccountInfoResult { + let networkParam = toNetwork(network) + return try await ServiceQueue.background(.core) { + try await onchainGetAccountInfo( + extendedKey: extendedKey, + electrumUrl: electrumUrl, + network: networkParam, + gapLimit: gapLimit, + scriptType: scriptType + ) + } + } + + /// Get address info (balance, UTXOs) for a single Bitcoin address. + /// This does NOT require a connected Trezor device — it queries the Electrum server directly. + func getAddressInfo( + address: String, + electrumUrl: String, + network: TrezorCoinType? = nil + ) async throws -> SingleAddressInfoResult { + let networkParam = toNetwork(network) + return try await ServiceQueue.background(.core) { + try await onchainGetAddressInfo( + address: address, + electrumUrl: electrumUrl, + network: networkParam + ) + } + } + + // MARK: - Transaction Composition & Broadcasting + + /// Compose a transaction using BDK-based PSBT generation (signer-agnostic). + /// Does NOT require a connected Trezor device. + func composeTransaction(params: ComposeParams) async throws -> [ComposeResult] { + try await ServiceQueue.background(.core) { + await onchainComposeTransaction(params: params) + } + } + + /// Broadcast a signed raw transaction via Electrum. + /// - Returns: The transaction ID (txid) + func broadcastRawTx(serializedTx: String, electrumUrl: String) async throws -> String { + try await ServiceQueue.background(.core) { + try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) + } + } + + // MARK: - Helpers + + /// Convert TrezorCoinType to the Network enum used by onchain FFI functions + private func toNetwork(_ coin: TrezorCoinType?) -> Network? { + guard let coin else { return nil } + switch coin { + case .bitcoin: return .bitcoin + case .testnet: return .testnet + case .signet: return .signet + case .regtest: return .regtest + } + } + + // MARK: - Credential Management + + /// Clear stored Bluetooth pairing credentials for a specific device + /// - Parameter deviceId: The device identifier + func clearCredentials(deviceId: String) async throws { + try await ServiceQueue.background(.core) { + try await trezorClearCredentials(deviceId: deviceId) + } + Logger.info("Cleared credentials for device: \(deviceId)", context: "TrezorService") + } +} diff --git a/Bitkit/Services/Trezor/TrezorTransport.swift b/Bitkit/Services/Trezor/TrezorTransport.swift new file mode 100644 index 00000000..a464a77b --- /dev/null +++ b/Bitkit/Services/Trezor/TrezorTransport.swift @@ -0,0 +1,296 @@ +import BitkitCore +import Combine +import CoreBluetooth +import Foundation + +/// Implementation of TrezorTransportCallback protocol +/// Coordinates BLE and USB transports for Trezor device communication +final class TrezorTransport: TrezorTransportCallback { + static let shared = TrezorTransport() + + private let bleManager = TrezorBLEManager.shared + + // MARK: - Pairing Code Handling + + /// Subject to notify UI when pairing code is needed + let needsPairingCodePublisher = PassthroughSubject() + + private var submittedPairingCode: String = "" + private let pairingCodeLock = NSLock() + + /// Timeout for pairing code entry (2 minutes) + private static let pairingCodeTimeoutSeconds: TimeInterval = 120 + + private init() {} + + // MARK: - Debug Logging + + /// Log to both Logger and in-app TrezorDebugLog + private func debugLog(_ message: String) { + Logger.debug(message, context: "TrezorTransport") + TrezorDebugLog.shared.log("[FFI] \(message)") + } + + // MARK: - TrezorTransportCallback Implementation + + /// Enumerate all connected/discovered Trezor devices + func enumerateDevices() -> [NativeDeviceInfo] { + let bleDevices = bleManager.enumerateDevices() + let devices = bleDevices.map { device in + NativeDeviceInfo( + path: device.path, + transportType: "bluetooth", + name: device.name, + vendorId: nil, + productId: nil + ) + } + + debugLog("enumerateDevices: \(devices.count) devices") + + return devices + } + + /// Open a connection to a device + func openDevice(path: String) -> TrezorTransportWriteResult { + debugLog("openDevice: \(path)") + + do { + guard path.hasPrefix("ble:") else { + throw TrezorTransportError.invalidPath(path) + } + + // Synchronously start async connection + // Note: This blocks the calling thread which is expected by Rust + let semaphore = DispatchSemaphore(value: 0) + var connectionError: Error? + + Task { + do { + try await bleManager.connect(path: path) + } catch { + connectionError = error + } + semaphore.signal() + } + + semaphore.wait() + + if let error = connectionError { + throw error + } + + return TrezorTransportWriteResult(success: true, error: "") + } catch { + debugLog("openDevice FAILED: \(error.localizedDescription)") + return TrezorTransportWriteResult(success: false, error: error.localizedDescription) + } + } + + /// Close a connection to a device + func closeDevice(path: String) -> TrezorTransportWriteResult { + debugLog("closeDevice: \(path)") + + guard path.hasPrefix("ble:") else { + return TrezorTransportWriteResult(success: false, error: "Invalid device path: \(path)") + } + + bleManager.disconnect(path: path) + return TrezorTransportWriteResult(success: true, error: "") + } + + /// Read a chunk of data from the device + func readChunk(path: String) -> TrezorTransportReadResult { + do { + guard path.hasPrefix("ble:") else { + throw TrezorTransportError.invalidPath(path) + } + + let data = try bleManager.readChunk(path: path) + debugLog("readChunk: \(data.count) bytes") + + return TrezorTransportReadResult(success: true, data: data, error: "") + } catch { + debugLog("readChunk FAILED: \(error.localizedDescription)") + return TrezorTransportReadResult(success: false, data: Data(), error: error.localizedDescription) + } + } + + /// Write a chunk of data to the device + func writeChunk(path: String, data: Data) -> TrezorTransportWriteResult { + debugLog("writeChunk: \(data.count) bytes") + + do { + guard path.hasPrefix("ble:") else { + throw TrezorTransportError.invalidPath(path) + } + + // Synchronously run async write + let semaphore = DispatchSemaphore(value: 0) + var writeError: Error? + + Task { + do { + try await bleManager.writeChunk(path: path, data: data) + } catch { + writeError = error + } + semaphore.signal() + } + + semaphore.wait() + + if let error = writeError { + throw error + } + + return TrezorTransportWriteResult(success: true, error: "") + } catch { + debugLog("writeChunk FAILED: \(error.localizedDescription)") + return TrezorTransportWriteResult(success: false, error: error.localizedDescription) + } + } + + /// Get the chunk size for a device + func getChunkSize(path: String) -> UInt32 { + return TrezorBLEManager.chunkSize // 244 bytes for BLE + } + + /// Called by rust-trezor to delegate full message call to native transport + /// This is an optional optimization - return nil to have Rust handle it + func callMessage(path: String, messageType: UInt16, data: Data) -> TrezorCallMessageResult? { + // Let Rust handle the message protocol + // We only provide the raw transport layer + return nil + } + + /// Get the pairing code from the user (blocks until user enters code) + /// This is called when the Trezor displays a 6-digit code for BLE pairing + func getPairingCode() -> String { + debugLog("getPairingCode: waiting for user input...") + + pairingCodeLock.lock() + submittedPairingCode = "" + pairingCodeLock.unlock() + + // Notify UI to show pairing code dialog + DispatchQueue.main.async { + self.needsPairingCodePublisher.send() + } + + // Block and wait for user to enter code + let code = blockForPairingCode() + + debugLog("getPairingCode: \(code.isEmpty ? "cancelled/empty" : "received")") + + return code + } + + /// Semaphore signalled when pairing code is submitted or cancelled + private let pairingCodeSemaphore = DispatchSemaphore(value: 0) + + /// Blocking wait for pairing code with timeout + private func blockForPairingCode() -> String { + let timeout = DispatchTime.now() + Self.pairingCodeTimeoutSeconds + let result = pairingCodeSemaphore.wait(timeout: timeout) + + if result == .timedOut { + return "" + } + + pairingCodeLock.lock() + let code = submittedPairingCode + pairingCodeLock.unlock() + + return code + } + + /// Called by UI when user submits pairing code + func submitPairingCode(_ code: String) { + debugLog("submitPairingCode") + + pairingCodeLock.lock() + submittedPairingCode = code + pairingCodeLock.unlock() + + pairingCodeSemaphore.signal() + } + + /// Cancel pairing code entry + func cancelPairingCode() { + debugLog("cancelPairingCode") + + pairingCodeLock.lock() + submittedPairingCode = "" + pairingCodeLock.unlock() + + pairingCodeSemaphore.signal() + } + + /// Save THP credential to secure storage + /// An empty credential string indicates a clear request + func saveThpCredential(deviceId: String, credentialJson: String) -> Bool { + // Empty credential means "clear" - delete the stored credential + if credentialJson.isEmpty { + debugLog("saveThpCredential: CLEAR device=\(deviceId)") + TrezorCredentialStorage.delete(deviceId: deviceId) + return true + } + + debugLog("saveThpCredential: device=\(deviceId) len=\(credentialJson.count)") + let result = TrezorCredentialStorage.save(deviceId: deviceId, json: credentialJson) + debugLog("saveThpCredential: \(result ? "OK" : "FAILED")") + return result + } + + /// Forward Rust-level debug messages to Logger and TrezorDebugLog + func logDebug(tag: String, message: String) { + Logger.debug("[\(tag)] \(message)", context: "TrezorTransport") + TrezorDebugLog.shared.log("[\(tag)] \(message)") + } + + /// Load THP credential from secure storage + func loadThpCredential(deviceId: String) -> String? { + debugLog("loadThpCredential: device=\(deviceId)") + + // List all stored credentials for debugging + let allDevices = TrezorCredentialStorage.listAllDeviceIds() + debugLog("loadThpCredential: stored IDs=\(allDevices)") + + let credential = TrezorCredentialStorage.load(deviceId: deviceId) + debugLog("loadThpCredential: \(credential != nil ? "FOUND len=\(credential!.count)" : "NOT FOUND")") + return credential + } + + + // MARK: - Device Scanning Helpers + + /// Start scanning for BLE devices + func startBLEScanning() { + bleManager.startScanning() + } + + /// Stop scanning for BLE devices + func stopBLEScanning() { + bleManager.stopScanning() + } + + /// Get Bluetooth state + var bluetoothState: CBManagerState { + bleManager.bluetoothState + } + +} + +// MARK: - Transport Errors + +enum TrezorTransportError: LocalizedError { + case invalidPath(String) + + var errorDescription: String? { + switch self { + case let .invalidPath(path): + return "Invalid device path: \(path)" + } + } +} diff --git a/Bitkit/Services/Trezor/TrezorUiHandler.swift b/Bitkit/Services/Trezor/TrezorUiHandler.swift new file mode 100644 index 00000000..ebeacb31 --- /dev/null +++ b/Bitkit/Services/Trezor/TrezorUiHandler.swift @@ -0,0 +1,171 @@ +import BitkitCore +import Combine +import Foundation + +/// Implementation of TrezorUiCallback protocol for PIN and passphrase handling. +/// Blocks the Rust calling thread until the user responds via the UI, +/// following the same semaphore pattern as TrezorTransport.getPairingCode(). +final class TrezorUiHandler: TrezorUiCallback { + static let shared = TrezorUiHandler() + + // MARK: - PIN Handling + + /// Publisher to notify UI when PIN entry is needed + let needsPinPublisher = PassthroughSubject() + + private var submittedPin: String = "" + private let pinLock = NSLock() + private let pinSemaphore = DispatchSemaphore(value: 0) + + // MARK: - Passphrase Handling + + /// Publisher to notify UI when passphrase entry is needed. + /// Bool parameter: true if passphrase should be entered on the device itself. + let needsPassphrasePublisher = PassthroughSubject() + + private var submittedPassphrase: String = "" + private let passphraseLock = NSLock() + private let passphraseSemaphore = DispatchSemaphore(value: 0) + + /// Tracks whether a passphrase request is actively blocking, + /// to prevent stale semaphore signals from dismissConfirmOnDevice(). + private var isAwaitingPassphrase = false + private let awaitingLock = NSLock() + + /// Timeout for PIN/passphrase entry (2 minutes) + private static let timeoutSeconds: TimeInterval = 120 + + private init() {} + + // MARK: - Debug Logging + + private func debugLog(_ message: String) { + Logger.debug(message, context: "TrezorUiHandler") + TrezorDebugLog.shared.log("[UI] \(message)") + } + + // MARK: - TrezorUiCallback Implementation + + func onPinRequest() -> String { + debugLog("onPinRequest: waiting for user input...") + + pinLock.lock() + submittedPin = "" + pinLock.unlock() + + // Notify UI to show PIN entry dialog + DispatchQueue.main.async { + self.needsPinPublisher.send() + } + + // Block and wait for user to enter PIN + let timeout = DispatchTime.now() + Self.timeoutSeconds + let result = pinSemaphore.wait(timeout: timeout) + + if result == .timedOut { + debugLog("onPinRequest: timed out") + return "" + } + + pinLock.lock() + let pin = submittedPin + pinLock.unlock() + + debugLog("onPinRequest: \(pin.isEmpty ? "cancelled" : "received")") + return pin + } + + func onPassphraseRequest(onDevice: Bool) -> String { + debugLog("onPassphraseRequest: onDevice=\(onDevice), waiting for user input...") + + passphraseLock.lock() + submittedPassphrase = "" + passphraseLock.unlock() + + awaitingLock.lock() + isAwaitingPassphrase = true + awaitingLock.unlock() + + // Notify UI + DispatchQueue.main.async { + self.needsPassphrasePublisher.send(onDevice) + } + + // Block and wait for user response + let timeout = DispatchTime.now() + Self.timeoutSeconds + let result = passphraseSemaphore.wait(timeout: timeout) + + awaitingLock.lock() + isAwaitingPassphrase = false + awaitingLock.unlock() + + if result == .timedOut { + debugLog("onPassphraseRequest: timed out") + return "" + } + + if onDevice { + // For on-device entry, return any non-empty string to acknowledge + debugLog("onPassphraseRequest(onDevice): acknowledged") + return "ok" + } + + passphraseLock.lock() + let passphrase = submittedPassphrase + passphraseLock.unlock() + + debugLog("onPassphraseRequest: \(passphrase.isEmpty ? "cancelled" : "received")") + return passphrase + } + + // MARK: - UI Submit/Cancel Methods + + /// Called by ViewModel when user submits PIN + func submitPin(_ pin: String) { + debugLog("submitPin") + pinLock.lock() + submittedPin = pin + pinLock.unlock() + pinSemaphore.signal() + } + + /// Called by ViewModel when user cancels PIN entry + func cancelPin() { + debugLog("cancelPin") + pinLock.lock() + submittedPin = "" + pinLock.unlock() + pinSemaphore.signal() + } + + /// Called by ViewModel when user submits passphrase + func submitPassphrase(_ passphrase: String) { + debugLog("submitPassphrase") + passphraseLock.lock() + submittedPassphrase = passphrase + passphraseLock.unlock() + passphraseSemaphore.signal() + } + + /// Called by ViewModel when user cancels passphrase entry + func cancelPassphrase() { + debugLog("cancelPassphrase") + passphraseLock.lock() + submittedPassphrase = "" + passphraseLock.unlock() + passphraseSemaphore.signal() + } + + /// Called by ViewModel when user acknowledges on-device passphrase entry. + /// Only signals if a passphrase request is actually pending. + func acknowledgeOnDevicePassphrase() { + awaitingLock.lock() + let awaiting = isAwaitingPassphrase + awaitingLock.unlock() + + guard awaiting else { return } + + debugLog("acknowledgeOnDevicePassphrase") + passphraseSemaphore.signal() + } +} diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index d8ff73db..33224881 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -97,6 +97,7 @@ enum Route: Hashable { case probingTool case orders case logs + case trezor } @MainActor diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift new file mode 100644 index 00000000..cb3ea1dc --- /dev/null +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -0,0 +1,1237 @@ +import BitkitCore +import Combine +import CoreBluetooth +import Foundation + +/// Represents the current step in the send transaction flow +enum SendStep { + case form + case review + case signed +} + +/// ViewModel for Trezor hardware wallet integration +@Observable +@MainActor +class TrezorViewModel { + // MARK: - Network Configuration + + /// The network selected in the Trezor dashboard (independent of app's global network) + var selectedNetwork: TrezorCoinType + + /// Map the app's current network to the corresponding TrezorCoinType (used for default initialization) + static var appDefaultCoinType: TrezorCoinType { + switch Env.network { + case .bitcoin: .bitcoin + case .testnet: .testnet + case .signet: .signet + case .regtest: .regtest + } + } + + /// BIP44 coin type component based on the dashboard's selected network: "0'" for mainnet, "1'" for test networks + var coinTypeComponent: String { + selectedNetwork == .bitcoin ? "0'" : "1'" + } + + /// BIP44 coin type component based on the app's global network (used for initial default values) + private static var defaultCoinTypeComponent: String { + Env.network == .bitcoin ? "0'" : "1'" + } + + // MARK: - Connection State + + /// Whether the Trezor manager is initialized + private var isInitialized: Bool = false + + /// Whether currently scanning for devices + var isScanning: Bool = false + + /// Whether currently performing an operation (address, signing, etc.) + var isOperating: Bool = false + + /// List of discovered devices + var devices: [TrezorDeviceInfo] = [] + + /// Currently connected device + var connectedDevice: TrezorDeviceInfo? + + /// Features of the connected device + var deviceFeatures: TrezorFeatures? + + /// Device root fingerprint (hex string) + var deviceFingerprint: String? + + /// Last error message + var error: String? + + // MARK: - UI Dialog State + + /// Show PIN entry dialog + var showPinEntry: Bool = false + + /// Show passphrase entry dialog + var showPassphraseEntry: Bool = false + + /// Show BLE pairing code dialog + var showPairingCode: Bool = false + + /// Show "Confirm on device" overlay + var showConfirmOnDevice: Bool = false + + /// Message for confirm on device overlay + var confirmMessage: String = "" + + // MARK: - Address Generation State + + /// Current derivation path + var derivationPath: String = "m/84'/\(defaultCoinTypeComponent)/0'/0/0" + + /// Current script type for address generation + var selectedScriptType: TrezorScriptType = .spendWitness + + /// Generated address + var generatedAddress: String? + + /// Whether to show address on device + var showAddressOnDevice: Bool = true + + // MARK: - Message Signing State + + /// Message to sign + var messageToSign: String = "Hello, Trezor!" + + /// Path for message signing + var messageSigningPath: String = "m/84'/\(defaultCoinTypeComponent)/0'/0/0" + + /// Signed message result + var signedMessage: TrezorSignedMessageResponse? + + // MARK: - Known Devices & Auto-Reconnect + + /// Previously connected devices loaded from storage + var knownDevices: [TrezorKnownDevice] = [] + + /// Whether auto-reconnect is in progress + var isAutoReconnecting: Bool = false + + /// Status text during auto-reconnect + var autoReconnectStatus: String? + + // MARK: - Address Index + + /// Current address index (last path component) + var addressIndex: UInt32 = 0 + + // MARK: - Public Key State + + /// Account-level derivation path for public key + var publicKeyPath: String = "m/84'/\(defaultCoinTypeComponent)/0'" + + /// Retrieved xpub string + var xpub: String? + + /// Retrieved compressed public key hex + var publicKeyHex: String? + + /// Whether to show public key on Trezor screen + var showPublicKeyOnDevice: Bool = false + + // MARK: - Debug Log + + /// Whether the debug log panel is expanded + var showDebugLog: Bool = false + + // MARK: - Balance Lookup State + + /// Whether a balance lookup is in progress + var isLookupLoading: Bool = false + + /// Error from the last balance lookup + var lookupError: String? + + /// Account info result from xpub lookup + var accountResult: AccountInfoResult? + + /// Single address info result from address lookup + var addressResult: SingleAddressInfoResult? + + // MARK: - Send Transaction State + + /// Destination address for the transaction + var sendAddress: String = "" + + /// Amount in satoshis to send + var sendAmountSats: String = "" + + /// Fee rate in sat/vB + var sendFeeRate: String = "2" + + /// Whether to send the maximum available balance + var isSendMax: Bool = false + + /// Whether transaction composition is in progress + var isComposing: Bool = false + + /// Coin selection strategy + var coinSelection: CoinSelection = .branchAndBound + + /// Composed transaction result (the Success variant) + var composeResult: ComposeResult? + + /// Signed transaction result + var signedTxResult: TrezorSignedTx? + + /// Current step in the send flow + var sendStep: SendStep = .form + + /// Whether a broadcast is in progress + var isBroadcasting: Bool = false + + /// Broadcast transaction ID (set after successful broadcast) + var broadcastTxid: String? + + /// Error specific to the send flow + var sendError: String? + + // MARK: - Bluetooth State + + /// Current Bluetooth state — reads directly from BLEManager (@Observable chaining) + var bluetoothState: CBManagerState { + TrezorBLEManager.shared.bluetoothState + } + + // MARK: - Private Properties + + private let trezorService = TrezorService.shared + private let transport = TrezorTransport.shared + private let uiHandler = TrezorUiHandler.shared + private var cancellables = Set() + private var hasSetupSubscriptions = false + + // MARK: - Initialization + + init() { + selectedNetwork = Self.appDefaultCoinType + // Callback subscriptions are deferred to initialize() to avoid + // triggering BLE stack and Combine overhead at app launch. + } + + /// Subscribe to callback publishers for UI notifications + private func setupCallbackSubscriptions() { + // Pairing code request + transport.needsPairingCodePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.showPairingCode = true + } + .store(in: &cancellables) + + // PIN request from device + uiHandler.needsPinPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.showPinEntry = true + } + .store(in: &cancellables) + + // Passphrase request from device + uiHandler.needsPassphrasePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] onDevice in + if onDevice { + self?.showConfirmOnDevice = true + self?.confirmMessage = "Enter passphrase on your Trezor" + } else { + self?.showPassphraseEntry = true + } + } + .store(in: &cancellables) + + } + + // MARK: - Debug Log Helper + + /// Log to both Logger and TrezorDebugLog + private func trezorLog(_ message: String, level: String = "info") { + switch level { + case "error": + Logger.error(message, context: "TrezorViewModel") + case "warn": + Logger.warn(message, context: "TrezorViewModel") + default: + Logger.info(message, context: "TrezorViewModel") + } + TrezorDebugLog.shared.log(message) + } + + // MARK: - Manager Setup + + /// Set up subscriptions and start BLE stack (synchronous, non-blocking). + /// Called from TrezorRootView's .task to prepare the UI layer. + func setup() { + guard !hasSetupSubscriptions else { return } + // Start BLE stack early so bluetoothState is updated by the time + // TrezorDeviceListView renders (the delegate callback fires async). + TrezorBLEManager.shared.ensureStarted() + setupCallbackSubscriptions() + hasSetupSubscriptions = true + } + + /// Initialize the Trezor FFI manager (async, may be slow). + /// Called lazily before first scan/connect. + func initialize() async { + setup() + + guard !isInitialized else { return } + + do { + try await trezorService.initialize() + isInitialized = true + error = nil + trezorLog("TrezorViewModel initialized") + } catch { + self.error = errorMessage(from: error) + trezorLog("Failed to initialize Trezor: \(error)", level: "error") + } + } + + // MARK: - Device Scanning + + /// Start scanning for Trezor devices + /// - Parameter clearExisting: Whether to clear existing device list before scanning + func startScan(clearExisting: Bool = true) async { + if !isInitialized { + await initialize() + } + + isScanning = true + error = nil + + if clearExisting { + devices = [] + } + + // Start BLE scanning + transport.startBLEScanning() + + // Wait for BLE to discover devices (like Android's 3-second scan) + // This ensures devices are found before we call the FFI enumerate + try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + + // Stop BLE scanning before calling FFI to prevent race conditions + transport.stopBLEScanning() + + do { + // Trigger FFI scan which will use our transport callbacks + let foundDevices = try await trezorService.scan() + + // Deduplicate by path (in case of duplicate scan results) + var seenPaths = Set() + let uniqueDevices = foundDevices.filter { device in + if seenPaths.contains(device.path) { + return false + } + seenPaths.insert(device.path) + return true + } + + devices = uniqueDevices + trezorLog("Found \(uniqueDevices.count) Trezor devices (filtered from \(foundDevices.count))") + } catch { + self.error = errorMessage(from: error) + trezorLog("Scan failed: \(error)", level: "error") + } + + isScanning = false + } + + /// Stop scanning for devices + func stopScan() { + transport.stopBLEScanning() + isScanning = false + } + + // MARK: - Connection + + /// Connect to a device + func connect(device: TrezorDeviceInfo) async { + error = nil + + trezorLog("=== Connecting to device: \(device.path) ===") + + do { + let features = try await trezorService.connect(deviceId: device.path) + connectedDevice = device + deviceFeatures = features + showConfirmOnDevice = false + + saveCurrentDeviceAsKnown() + trezorLog("Connected to Trezor: \(device.path)") + } catch { + let errorMsg = errorMessage(from: error) + self.error = errorMsg + showConfirmOnDevice = false + trezorLog("Connection failed: \(error)", level: "error") + } + } + + /// Disconnect from current device + func disconnect() async { + guard connectedDevice != nil else { return } + + do { + try await trezorService.disconnect() + // Clear connection state but preserve device list for quick reconnection + connectedDevice = nil + deviceFeatures = nil + deviceFingerprint = nil + generatedAddress = nil + signedMessage = nil + xpub = nil + publicKeyHex = nil + error = nil + showPinEntry = false + showPassphraseEntry = false + showConfirmOnDevice = false + + trezorLog("Disconnected from Trezor") + } catch { + // Even if disconnect fails, clear local state + connectedDevice = nil + deviceFeatures = nil + self.error = errorMessage(from: error) + trezorLog("Disconnect failed: \(error)", level: "error") + } + } + + /// Check if currently connected + var isConnected: Bool { + connectedDevice != nil + } + + // MARK: - Address Operations + + /// Get address from connected device + /// - Parameter showOnDevice: Whether to display address on Trezor screen + func getAddress(showOnDevice: Bool = true) async { + guard isConnected else { + error = "Not connected to a Trezor" + return + } + + isOperating = true + error = nil + + do { + let params = TrezorGetAddressParams( + path: derivationPath, + coin: selectedNetwork, + showOnTrezor: showOnDevice, + scriptType: selectedScriptType + ) + + let response = try await trezorService.getAddress(params: params) + generatedAddress = response.address + showConfirmOnDevice = false + + trezorLog("Generated address: \(response.address)") + } catch { + self.error = errorMessage(from: error) + showConfirmOnDevice = false + trezorLog("Get address failed: \(error)", level: "error") + } + + isOperating = false + } + + // MARK: - Message Signing + + /// Sign a message with the connected device + func signMessage() async { + guard isConnected else { + error = "Not connected to a Trezor" + return + } + + guard !messageToSign.isEmpty else { + error = "Please enter a message to sign" + return + } + + isOperating = true + error = nil + + do { + let params = TrezorSignMessageParams( + path: messageSigningPath, + message: messageToSign, + coin: selectedNetwork + ) + + let response = try await trezorService.signMessage(params: params) + signedMessage = response + showConfirmOnDevice = false + + trezorLog("Message signed successfully") + } catch { + self.error = errorMessage(from: error) + showConfirmOnDevice = false + trezorLog("Sign message failed: \(error)", level: "error") + } + + isOperating = false + } + + /// Verify a signed message + func verifyMessage(address: String, signature: String, message: String) async -> Bool { + guard isConnected else { + error = "Not connected to a Trezor" + return false + } + + isOperating = true + error = nil + + do { + let params = TrezorVerifyMessageParams( + address: address, + signature: signature, + message: message, + coin: selectedNetwork + ) + + let isValid = try await trezorService.verifyMessage(params: params) + showConfirmOnDevice = false + + trezorLog("Message verification result: \(isValid)") + + isOperating = false + return isValid + } catch { + self.error = errorMessage(from: error) + showConfirmOnDevice = false + trezorLog("Verify message failed: \(error)", level: "error") + + isOperating = false + return false + } + } + + // MARK: - UI Callbacks + + /// Submit PIN from UI + func submitPin(_ pin: String) { + showPinEntry = false + uiHandler.submitPin(pin) + } + + /// Cancel PIN entry + func cancelPin() { + showPinEntry = false + uiHandler.cancelPin() + } + + /// Submit passphrase from UI + func submitPassphrase(_ passphrase: String) { + showPassphraseEntry = false + showConfirmOnDevice = false + uiHandler.submitPassphrase(passphrase) + } + + /// Cancel passphrase entry + func cancelPassphrase() { + showPassphraseEntry = false + showConfirmOnDevice = false + uiHandler.cancelPassphrase() + } + + /// Submit pairing code from UI + func submitPairingCode(_ code: String) { + showPairingCode = false + transport.submitPairingCode(code) + } + + /// Cancel pairing code entry + func cancelPairingCode() { + showPairingCode = false + transport.cancelPairingCode() + } + + /// Dismiss confirm on device overlay + func dismissConfirmOnDevice() { + showConfirmOnDevice = false + confirmMessage = "" + uiHandler.acknowledgeOnDevicePassphrase() + } + + // MARK: - Known Devices + + /// Load known devices from storage + func loadKnownDevices() { + knownDevices = TrezorKnownDeviceStorage.loadAll() + } + + /// Save the currently connected device as a known device + func saveCurrentDeviceAsKnown() { + guard let device = connectedDevice else { return } + let known = TrezorKnownDevice( + id: device.id, + name: device.name ?? "Trezor", + path: device.path, + transportType: device.transportType == .bluetooth ? "bluetooth" : "usb", + label: device.label ?? deviceFeatures?.label, + model: device.model ?? deviceFeatures?.model, + lastConnectedAt: Date() + ) + TrezorKnownDeviceStorage.save(known) + loadKnownDevices() + trezorLog("Saved known device: \(known.name)") + } + + /// Forget a known device — removes from storage and clears credentials + func forgetDevice(id: String) async { + // Find the device to get its path for credential clearing + if let device = knownDevices.first(where: { $0.id == id }) { + do { + try await trezorService.clearCredentials(deviceId: device.path) + } catch { + trezorLog("Failed to clear credentials for forgotten device: \(error)", level: "warn") + } + TrezorCredentialStorage.delete(deviceId: device.path) + } + TrezorKnownDeviceStorage.remove(id: id) + loadKnownDevices() + trezorLog("Forgot device: \(id)") + } + + // MARK: - Auto-Reconnect + + /// Automatically scan and reconnect to the first matching known device + func autoReconnect() async { + guard !knownDevices.isEmpty else { return } + guard !isAutoReconnecting else { return } + + isAutoReconnecting = true + autoReconnectStatus = "Scanning for known devices..." + trezorLog("Auto-reconnect: starting scan") + + await startScan(clearExisting: true) + + // Find the first scanned device that matches a known device + let knownIds = Set(knownDevices.map(\.id)) + if let match = devices.first(where: { knownIds.contains($0.id) }) { + autoReconnectStatus = "Connecting to \(match.label ?? match.name ?? "Trezor")..." + trezorLog("Auto-reconnect: found known device \(match.path)") + await connect(device: match) + } else { + autoReconnectStatus = nil + trezorLog("Auto-reconnect: no known devices found nearby") + } + + isAutoReconnecting = false + autoReconnectStatus = nil + } + + // MARK: - Address Index + + /// Increment the address index and update derivation path + func incrementAddressIndex() { + addressIndex += 1 + updateDerivationPathIndex() + } + + /// Decrement the address index (minimum 0) and update derivation path + func decrementAddressIndex() { + guard addressIndex > 0 else { return } + addressIndex -= 1 + updateDerivationPathIndex() + } + + /// Update the last component of the derivation path to match addressIndex + private func updateDerivationPathIndex() { + var components = derivationPath.split(separator: "/") + guard components.count >= 2 else { return } + components[components.count - 1] = Substring("\(addressIndex)") + derivationPath = components.joined(separator: "/") + } + + // MARK: - Public Key Operations + + /// Get public key (xpub) from connected device + func getPublicKey(showOnDevice: Bool = false) async { + guard isConnected else { + error = "Not connected to a Trezor" + return + } + + isOperating = true + error = nil + + do { + let params = TrezorGetPublicKeyParams( + path: publicKeyPath, + coin: selectedNetwork, + showOnTrezor: showOnDevice + ) + + let response = try await trezorService.getPublicKey(params: params) + xpub = response.xpub + publicKeyHex = response.publicKey + showConfirmOnDevice = false + + trezorLog("Got public key for path: \(publicKeyPath)") + } catch { + self.error = errorMessage(from: error) + showConfirmOnDevice = false + trezorLog("Get public key failed: \(error)", level: "error") + } + + isOperating = false + } + + // MARK: - Transaction Signing + + /// Sign a Bitcoin transaction + func signTx(params: TrezorSignTxParams) async -> TrezorSignedTx? { + guard isConnected else { + error = "Not connected to a Trezor" + return nil + } + + isOperating = true + error = nil + + do { + let result = try await trezorService.signTx(params: params) + showConfirmOnDevice = false + trezorLog("Transaction signed successfully") + isOperating = false + return result + } catch { + self.error = errorMessage(from: error) + showConfirmOnDevice = false + trezorLog("Sign tx failed: \(error)", level: "error") + isOperating = false + return nil + } + } + + // MARK: - PSBT Signing + + /// Sign a Bitcoin transaction from a PSBT (base64-encoded) + /// - Parameter psbtBase64: Base64-encoded PSBT data + /// - Returns: The signed transaction, or nil on failure + func signTxFromPsbt(psbtBase64: String) async -> TrezorSignedTx? { + guard isConnected else { + error = "Not connected to a Trezor" + return nil + } + + isOperating = true + error = nil + + do { + let result = try await trezorService.signTxFromPsbt( + psbtBase64: psbtBase64, + network: selectedNetwork + ) + showConfirmOnDevice = false + trezorLog("PSBT signed successfully") + isOperating = false + return result + } catch { + self.error = errorMessage(from: error) + showConfirmOnDevice = false + trezorLog("Sign PSBT failed: \(error)", level: "error") + isOperating = false + return nil + } + } + + // MARK: - Device Fingerprint + + /// Get the device's master root fingerprint + func getDeviceFingerprint() async { + guard isConnected else { + error = "Not connected to a Trezor" + return + } + + isOperating = true + error = nil + + do { + let fingerprint = try await trezorService.getDeviceFingerprint() + deviceFingerprint = fingerprint + trezorLog("Device fingerprint: \(fingerprint)") + } catch { + self.error = errorMessage(from: error) + trezorLog("Get fingerprint failed: \(error)", level: "error") + } + + isOperating = false + } + + // MARK: - Electrum URL Helpers + + /// Get the Electrum server URL for a specific network (hardcoded per-network URLs) + static func electrumUrlForNetwork(_ network: TrezorCoinType) -> String { + switch network { + case .bitcoin: "ssl://bitkit.to:9999" + case .testnet, .signet: "ssl://electrum.blockstream.info:60002" + case .regtest: "ssl://electrs.bitkit.stag0.blocktank.to:9999" + } + } + + /// Get the current Electrum server URL from configuration (uses app's configured server) + static func getElectrumUrl() -> String { + let configService = ElectrumConfigService() + let server = configService.getCurrentServer() + return server.fullUrl.isEmpty ? Env.electrumServerUrl : server.fullUrl + } + + // MARK: - Network Switching + + /// Switch the dashboard's network independently of the app's global network + func setSelectedNetwork(_ network: TrezorCoinType) { + guard network != selectedNetwork else { return } + selectedNetwork = network + + // Reset derivation paths with the new coin type + derivationPath = "m/84'/\(coinTypeComponent)/0'/0/0" + publicKeyPath = "m/84'/\(coinTypeComponent)/0'" + messageSigningPath = "m/84'/\(coinTypeComponent)/0'/0/0" + addressIndex = 0 + + // Clear results from previous network + generatedAddress = nil + xpub = nil + publicKeyHex = nil + signedMessage = nil + error = nil + accountResult = nil + addressResult = nil + lookupError = nil + resetSendFlow() + + trezorLog("Switched dashboard network to \(network)") + } + + // MARK: - Balance Lookup Operations + + /// Input type detection for balance lookup + enum LookupInputType { + case address + case extendedKey + case unknown + } + + /// Detect the type of input string + static func detectInputType(_ input: String) -> LookupInputType { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .unknown } + let xpubPrefixes = ["xpub", "ypub", "zpub", "tpub", "upub", "vpub"] + if xpubPrefixes.contains(where: { trimmed.hasPrefix($0) }) { + return .extendedKey + } + if trimmed.hasPrefix("1") || trimmed.hasPrefix("3") || + trimmed.hasPrefix("bc1") || trimmed.hasPrefix("tb1") || + trimmed.hasPrefix("bcrt1") || trimmed.hasPrefix("m") || + trimmed.hasPrefix("n") || trimmed.hasPrefix("2") + { + return .address + } + return .unknown + } + + /// Perform a balance lookup for the given input (address or xpub) + func performLookup(input: String) async { + let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedInput.isEmpty else { return } + + isLookupLoading = true + lookupError = nil + accountResult = nil + addressResult = nil + resetSendFlow() + + let electrumUrl = Self.electrumUrlForNetwork(selectedNetwork) + + do { + switch Self.detectInputType(trimmedInput) { + case .extendedKey: + accountResult = try await trezorService.getAccountInfo( + extendedKey: trimmedInput, + electrumUrl: electrumUrl, + network: selectedNetwork + ) + case .address: + addressResult = try await trezorService.getAddressInfo( + address: trimmedInput, + electrumUrl: electrumUrl, + network: selectedNetwork + ) + case .unknown: + lookupError = "Unrecognized input. Enter a Bitcoin address or extended public key (xpub/ypub/zpub/tpub/upub/vpub)." + } + } catch { + lookupError = formatLookupError(error) + } + + isLookupLoading = false + } + + /// Format balance lookup errors for user display + private func formatLookupError(_ error: Error) -> String { + if let accountError = error as? AccountInfoError { + switch accountError { + case let .InvalidExtendedKey(errorDetails): + return "Invalid extended public key: \(errorDetails)" + case let .InvalidAddress(errorDetails): + return "Invalid Bitcoin address: \(errorDetails)" + case let .ElectrumError(errorDetails): + return "Electrum connection failed: \(errorDetails)" + case let .WalletError(errorDetails): + return "Wallet error: \(errorDetails)" + case let .SyncError(errorDetails): + return "Sync failed: \(errorDetails)" + case let .UnsupportedKeyType(errorDetails): + return "Unsupported key type: \(errorDetails)" + case let .NetworkMismatch(errorDetails): + return "Network mismatch: \(errorDetails)" + case let .InvalidTxid(errorDetails): + return "Invalid transaction ID: \(errorDetails)" + } + } + if let appError = error as? AppError, + let debugMessage = appError.debugMessage, !debugMessage.isEmpty + { + return debugMessage + } + return error.localizedDescription + } + + // MARK: - Send Transaction Operations + + /// Toggle the send-max flag + func toggleSendMax() { + isSendMax.toggle() + } + + /// Set the coin selection strategy + func setCoinSelection(_ selection: CoinSelection) { + coinSelection = selection + } + + /// Compose a transaction using BDK-based PSBT generation + /// - Parameters: + /// - extendedKey: The extended public key (xpub/ypub/zpub) used for the balance lookup + /// - accountInfo: The account info from a prior xpub balance lookup + func composeTx(extendedKey: String, accountInfo: AccountInfoResult) async { + // Validate inputs + let address = sendAddress.trimmingCharacters(in: .whitespacesAndNewlines) + guard !address.isEmpty else { + sendError = "Enter a destination address" + return + } + if !isSendMax { + guard let amount = UInt64(sendAmountSats.trimmingCharacters(in: .whitespacesAndNewlines)), amount > 0 else { + sendError = "Enter a valid amount" + return + } + } + guard let feeRate = Float(sendFeeRate), feeRate > 0 else { + sendError = "Enter a valid fee rate" + return + } + + isComposing = true + sendError = nil + + // Ensure we have the device fingerprint for proper PSBT derivation paths. + // Without it, BDK produces relative paths (e.g. m/0/0) that the Trezor + // rejects as "Forbidden key path". + if deviceFingerprint == nil { + do { + deviceFingerprint = try await trezorService.getDeviceFingerprint() + } catch { + trezorLog("Failed to get device fingerprint: \(error)", level: "error") + sendError = "Failed to get device fingerprint" + isComposing = false + return + } + } + + trezorLog("=== composeTx START ===") + trezorLog("address=\(address), amount=\(sendAmountSats), sendMax=\(isSendMax)") + trezorLog("feeRate=\(sendFeeRate) sat/vB, coinSelection=\(String(describing: coinSelection))") + trezorLog("balance=\(accountInfo.balance)") + + let output: ComposeOutput = isSendMax + ? .sendMax(address: address) + : .payment(address: address, amountSats: UInt64(sendAmountSats) ?? 0) + + let electrumUrl = Self.electrumUrlForNetwork(selectedNetwork) + let network = toNetwork(selectedNetwork) + + let wallet = WalletParams( + extendedKey: extendedKey, + electrumUrl: electrumUrl, + fingerprint: deviceFingerprint, + network: network, + accountType: accountInfo.accountType + ) + + let params = ComposeParams( + wallet: wallet, + outputs: [output], + feeRates: [feeRate], + coinSelection: coinSelection + ) + + do { + let results = try await trezorService.composeTransaction(params: params) + handleComposeResults(results) + } catch { + trezorLog("composeTx FAILED: \(error)", level: "error") + sendError = errorMessage(from: error) + isComposing = false + } + } + + /// Process compose results, extracting the first Success result + private func handleComposeResults(_ results: [ComposeResult]) { + trezorLog("Got \(results.count) compose result(s)") + + var successResult: ComposeResult? + var errorMsg: String? + + for (i, result) in results.enumerated() { + switch result { + case let .success(psbt, fee, feeRate, totalSpent): + trezorLog("[\(i)] Success: fee=\(fee), feeRate=\(feeRate), totalSpent=\(totalSpent), psbtLen=\(psbt.count)") + if successResult == nil { + successResult = result + } + case let .error(error): + trezorLog("[\(i)] Error: \(error)") + if errorMsg == nil { + errorMsg = error + } + } + } + + if successResult != nil { + trezorLog("=== composeTx SUCCESS ===") + composeResult = successResult + sendStep = .review + } else if let errorMsg { + trezorLog("=== composeTx FAILED (compose error) ===") + sendError = errorMsg + } else { + trezorLog("=== composeTx FAILED (no valid result) ===") + sendError = "No valid composition returned" + } + + isComposing = false + } + + /// Sign the composed PSBT with the connected Trezor device + func signComposedTx() async { + guard let result = composeResult else { return } + + // Extract PSBT from the Success case + guard case let .success(psbt, _, _, _) = result else { + sendError = "No valid compose result to sign" + return + } + + guard isConnected else { + sendError = "Not connected to a Trezor" + return + } + + isOperating = true + sendError = nil + + trezorLog("=== signComposedTx START ===") + trezorLog("psbtLen=\(psbt.count)") + + do { + // Sign PSBT directly with Trezor + trezorLog("Calling trezor signTxFromPsbt...") + let signedTx = try await trezorService.signTxFromPsbt( + psbtBase64: psbt, + network: selectedNetwork + ) + showConfirmOnDevice = false + + trezorLog("=== signComposedTx SUCCESS ===") + trezorLog("signatures=\(signedTx.signatures.count), txid=\(signedTx.txid ?? "nil"), rawTxLen=\(signedTx.serializedTx.count)") + + signedTxResult = signedTx + sendStep = .signed + } catch { + showConfirmOnDevice = false + trezorLog("signComposedTx FAILED: \(error)", level: "error") + sendError = self.errorMessage(from: error) + } + + isOperating = false + } + + /// Broadcast the signed transaction via Electrum + func broadcastSignedTx() async { + guard let rawTx = signedTxResult?.serializedTx else { return } + + isBroadcasting = true + sendError = nil + + let electrumUrl = Self.electrumUrlForNetwork(selectedNetwork) + + do { + let txid = try await trezorService.broadcastRawTx(serializedTx: rawTx, electrumUrl: electrumUrl) + trezorLog("BROADCAST SUCCESS txid=\(txid)") + broadcastTxid = txid + } catch { + trezorLog("BROADCAST FAILED: \(error)", level: "error") + sendError = errorMessage(from: error) + } + + isBroadcasting = false + } + + /// Reset all send flow state to defaults + func resetSendFlow() { + sendAddress = "" + sendAmountSats = "" + sendFeeRate = "2" + isSendMax = false + isComposing = false + coinSelection = .branchAndBound + composeResult = nil + signedTxResult = nil + sendStep = .form + isBroadcasting = false + broadcastTxid = nil + sendError = nil + } + + /// Go back from review to compose form + func backToComposeForm() { + sendStep = .form + composeResult = nil + signedTxResult = nil + sendError = nil + } + + // MARK: - Helpers + + /// Convert TrezorCoinType to the Network enum used by onchain FFI functions + private func toNetwork(_ coin: TrezorCoinType) -> Network? { + switch coin { + case .bitcoin: return .bitcoin + case .testnet: return .testnet + case .signet: return .signet + case .regtest: return .regtest + } + } + + // MARK: - Error Handling + + /// 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 + } + + // MARK: - Credential Management + + /// Clear stored credentials for current device + func clearCredentials() async { + guard let device = connectedDevice else { + error = "No device connected" + return + } + + do { + try await trezorService.clearCredentials(deviceId: device.path) + trezorLog("Cleared credentials for \(device.path)") + } catch { + self.error = errorMessage(from: error) + trezorLog("Failed to clear credentials: \(error)", level: "error") + } + } +} diff --git a/Bitkit/Views/Settings/Advanced/AddressViewer.swift b/Bitkit/Views/Settings/Advanced/AddressViewer.swift index 536d7fbb..a4878e6e 100644 --- a/Bitkit/Views/Settings/Advanced/AddressViewer.swift +++ b/Bitkit/Views/Settings/Advanced/AddressViewer.swift @@ -19,7 +19,7 @@ struct AddressViewer: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var settings: SettingsViewModel - @State private var addresses: [BitkitCore.AddressInfo] = [] + @State private var addresses: [AddressInfo] = [] @State private var addressBalances: [String: UInt64] = [:] @State private var loadedCount: UInt32 = 20 @State private var isLoading = false @@ -69,7 +69,7 @@ struct AddressViewer: View { return 0 // Default to first address index } - private var filteredAddresses: [BitkitCore.AddressInfo] { + private var filteredAddresses: [AddressInfo] { if searchText.isEmpty { return addresses } diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index c693806d..b4da8dad 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -4,6 +4,7 @@ struct AdvancedSettingsView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var suggestionsManager: SuggestionsManager @EnvironmentObject var settings: SettingsViewModel + @AppStorage("showDevSettings") private var showDevSettings = Env.isDebug @State private var showingResetAlert = false var body: some View { @@ -67,6 +68,20 @@ struct AdvancedSettingsView: View { .accessibilityIdentifier("RGSServer") } + // HARDWARE WALLET Section + if showDevSettings { + VStack(alignment: .leading, spacing: 0) { + CaptionMText("Hardware Wallet") + .padding(.top, 24) + .padding(.bottom, 8) + + NavigationLink(value: Route.trezor) { + SettingsListLabel(title: "Trezor") + } + .accessibilityIdentifier("Trezor") + } + } + // OTHER Section VStack(alignment: .leading, spacing: 0) { CaptionMText( diff --git a/Bitkit/Views/Trezor/TrezorAddressView.swift b/Bitkit/Views/Trezor/TrezorAddressView.swift new file mode 100644 index 00000000..5bb2c484 --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorAddressView.swift @@ -0,0 +1,398 @@ +import BitkitCore +import CoreImage.CIFilterBuiltins +import SwiftUI + +// MARK: - Script Type + +enum TrezorAddressScriptType: String, CaseIterable { + case legacy = "Legacy (P2PKH)" + case segwit = "Native SegWit (P2WPKH)" + case nestedSegwit = "Nested SegWit (P2SH-P2WPKH)" + case taproot = "Taproot (P2TR)" + + var trezorScriptType: TrezorScriptType { + switch self { + case .legacy: + return .spendAddress + case .segwit: + return .spendWitness + case .nestedSegwit: + return .spendP2shWitness + case .taproot: + return .spendTaproot + } + } + + func defaultPath(coinType: String) -> String { + switch self { + case .legacy: + return "m/44'/\(coinType)/0'/0/0" + case .segwit: + return "m/84'/\(coinType)/0'/0/0" + case .nestedSegwit: + return "m/49'/\(coinType)/0'/0/0" + case .taproot: + return "m/86'/\(coinType)/0'/0/0" + } + } +} + +/// Inline content for address generation, used by expandable section. +struct TrezorAddressContent: View { + @Environment(TrezorViewModel.self) private var trezor + @State private var selectedScriptType: TrezorAddressScriptType = .segwit + + var body: some View { + VStack(spacing: 24) { + AddressTypeSection(selectedScriptType: $selectedScriptType) + DerivationPathSection(selectedScriptType: selectedScriptType) + VerifyOnDeviceSection() + GenerateButtonSection() + AddressResultSection() + } + .onChange(of: selectedScriptType) { newValue in + trezor.derivationPath = newValue.defaultPath(coinType: trezor.coinTypeComponent) + trezor.selectedScriptType = newValue.trezorScriptType + trezor.addressIndex = 0 + } + .task { + trezor.selectedScriptType = selectedScriptType.trezorScriptType + } + } +} + +/// Full-screen view for generating addresses from Trezor (used for previews) +struct TrezorAddressView: View { + var body: some View { + ScrollView { + TrezorAddressContent() + .padding(16) + } + .scrollDismissesKeyboard(.interactively) + .background(Color.black) + .navigationTitle("Get Address") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Address Type Section + +private struct AddressTypeSection: View { + @Binding var selectedScriptType: TrezorAddressScriptType + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Address Type") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + Picker("Script Type", selection: $selectedScriptType) { + ForEach(TrezorAddressScriptType.allCases, id: \.self) { type in + Text(type.rawValue).tag(type) + } + } + .pickerStyle(.menu) + .tint(.green) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } +} + +// MARK: - Derivation Path Section + +private struct DerivationPathSection: View { + @Environment(TrezorViewModel.self) private var trezor + let selectedScriptType: TrezorAddressScriptType + @FocusState private var isFieldFocused: Bool + + var body: some View { + @Bindable var trezor = trezor + VStack(alignment: .leading, spacing: 12) { + Text("Derivation Path") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + SwiftUI.TextField("m/84'/0'/0'/0/0", text: $trezor.derivationPath) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(.white) + .focused($isFieldFocused) + .submitLabel(.done) + .onSubmit { + isFieldFocused = false + } + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Address index stepper + HStack { + Text("Address Index: \(trezor.addressIndex)") + .font(.system(size: 14)) + .foregroundColor(.white) + + Spacer() + + Button(action: { trezor.decrementAddressIndex() }) { + Image(systemName: "minus.circle") + .font(.system(size: 20)) + .foregroundColor(trezor.addressIndex == 0 ? .white.opacity(0.2) : .white.opacity(0.6)) + } + .disabled(trezor.addressIndex == 0) + + Button(action: { trezor.incrementAddressIndex() }) { + Image(systemName: "plus.circle") + .font(.system(size: 20)) + .foregroundColor(.white.opacity(0.6)) + } + } + .padding(12) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Button(action: { + trezor.derivationPath = selectedScriptType.defaultPath(coinType: trezor.coinTypeComponent) + trezor.addressIndex = 0 + }) { + Text("Use default path") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + } + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + isFieldFocused = false + } + } + } + } +} + +// MARK: - Verify On Device Section + +private struct VerifyOnDeviceSection: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + @Bindable var trezor = trezor + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Verify on Trezor") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + Text("Display address on device for verification") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + } + + Spacer() + + Toggle("", isOn: $trezor.showAddressOnDevice) + .labelsHidden() + .tint(.green) + } + .padding(16) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Generate Button Section + +private struct GenerateButtonSection: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + Button(action: { + Task { + await trezor.getAddress(showOnDevice: trezor.showAddressOnDevice) + } + }) { + HStack(spacing: 8) { + if trezor.isOperating { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } else { + Image(systemName: "qrcode") + } + Text("Generate Address") + } + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(trezor.isOperating) + } +} + +// MARK: - Address Result Section + +private struct AddressResultSection: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + VStack(spacing: 24) { + if let address = trezor.generatedAddress { + GeneratedAddressCard(address: address) + + // Next Index button + Button(action: { + trezor.incrementAddressIndex() + trezor.generatedAddress = nil + }) { + HStack(spacing: 8) { + Image(systemName: "arrow.right") + Text("Next Index (\(trezor.addressIndex + 1))") + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.white.opacity(0.15)) + .clipShape(Capsule()) + } + } + + if let error = trezor.error { + TrezorErrorBanner(message: error) + } + } + } +} + +// MARK: - Generated Address Card + +private struct GeneratedAddressCard: View { + let address: String + @State private var copied = false + + var body: some View { + VStack(spacing: 16) { + QRCodeView(content: address) + AddressText(address: address) + CopyButton(address: address, copied: $copied) + } + .padding(24) + .frame(maxWidth: .infinity) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +private struct QRCodeView: View { + let content: String + @State private var qrImage: UIImage? + + var body: some View { + Group { + if let qrImage { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 180, height: 180) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color.white) + .frame(width: 180, height: 180) + + Image(systemName: "qrcode") + .font(.system(size: 80)) + .foregroundColor(.black.opacity(0.2)) + } + } + } + .task(id: content) { + qrImage = Self.generateQRCode(from: content) + } + } + + private static func generateQRCode(from string: String) -> UIImage? { + let data = string.data(using: .utf8) + + guard let filter = CIFilter(name: "CIQRCodeGenerator") else { + return nil + } + + filter.setValue(data, forKey: "inputMessage") + filter.setValue("H", forKey: "inputCorrectionLevel") + + guard let ciImage = filter.outputImage else { + return nil + } + + let scale = 10.0 + let transform = CGAffineTransform(scaleX: scale, y: scale) + let scaledImage = ciImage.transformed(by: transform) + + let context = CIContext() + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { + return nil + } + + return UIImage(cgImage: cgImage) + } +} + +private struct AddressText: View { + let address: String + + var body: some View { + Text(address) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } +} + +private struct CopyButton: View { + let address: String + @Binding var copied: Bool + + var body: some View { + Button(action: copyAddress) { + HStack(spacing: 8) { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + Text(copied ? "Copied!" : "Copy Address") + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.white.opacity(0.15)) + .clipShape(Capsule()) + } + } + + private func copyAddress() { + UIPasteboard.general.string = address + copied = true + + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copied = false + } + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorAddressView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + TrezorAddressView() + } + .environment(TrezorViewModel()) + } + } +#endif diff --git a/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift new file mode 100644 index 00000000..9f02dc10 --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorBalanceLookupView.swift @@ -0,0 +1,444 @@ +import BitkitCore +import SwiftUI + +/// Inline content for balance lookup, used by expandable section. +struct TrezorBalanceLookupContent: View { + @State private var input: String = "" + + var body: some View { + VStack(spacing: 24) { + InputSection(input: $input) + + LookupButtonWrapper(input: input) + + BalanceLookupResultsSection(input: input) + } + } +} + +/// Full-screen view for looking up balance and UTXOs (used for previews). +/// Does NOT require a connected Trezor device — queries Electrum directly. +/// When an xpub lookup returns balance > 0, shows the send transaction section. +struct TrezorBalanceLookupView: View { + var body: some View { + ScrollView { + TrezorBalanceLookupContent() + .padding(16) + } + .scrollDismissesKeyboard(.immediately) + .background(Color.black) + .navigationTitle("Balance Lookup") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Lookup Button Wrapper + +/// Isolates ViewModel access for the lookup button so the parent body stays cheap. +private struct LookupButtonWrapper: View { + let input: String + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + LookupButton(isLoading: trezor.isLookupLoading, isDisabled: input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { + await trezor.performLookup(input: input) + } + } +} + +// MARK: - Results Section Wrapper + +/// Isolates all ViewModel-dependent result/send UI into its own view, +/// keeping the parent body free of ViewModel property accesses. +private struct BalanceLookupResultsSection: View { + let input: String + @Environment(TrezorViewModel.self) private var trezor + + private var hasResults: Bool { + trezor.accountResult != nil || trezor.addressResult != nil + } + + var body: some View { + @Bindable var trezor = trezor + Group { + if let accountResult = trezor.accountResult { + AccountResultSection(result: accountResult) + } + + if let addressResult = trezor.addressResult { + AddressResultSection(result: addressResult) + } + + if hasResults { + UTXOListSection(utxos: trezor.accountResult?.account.utxo ?? trezor.addressResult?.utxos ?? []) + } + + // Show send transaction section when xpub has balance + if let accountResult = trezor.accountResult, accountResult.balance > 0 { + SendTransactionSection( + sendAddress: $trezor.sendAddress, + sendAmountSats: $trezor.sendAmountSats, + sendFeeRate: $trezor.sendFeeRate, + isSendMax: trezor.isSendMax, + coinSelection: trezor.coinSelection, + sendStep: trezor.sendStep, + isComposing: trezor.isComposing, + isOperating: trezor.isOperating, + isBroadcasting: trezor.isBroadcasting, + isDeviceConnected: trezor.isConnected, + composeResult: trezor.composeResult, + signedTxResult: trezor.signedTxResult, + broadcastTxid: trezor.broadcastTxid, + sendError: trezor.sendError, + onToggleSendMax: { trezor.toggleSendMax() }, + onCoinSelectionChange: { trezor.setCoinSelection($0) }, + onCompose: { Task { await trezor.composeTx(extendedKey: input, accountInfo: accountResult) } }, + onSign: { Task { await trezor.signComposedTx() } }, + onBroadcast: { Task { await trezor.broadcastSignedTx() } }, + onBack: { trezor.backToComposeForm() }, + onReset: { trezor.resetSendFlow() } + ) + } + + if let error = trezor.lookupError { + TrezorErrorBanner(message: error) + } + } + } +} + +// MARK: - Input Type for sub-views + +extension TrezorBalanceLookupView { + typealias InputType = TrezorViewModel.LookupInputType +} + +// MARK: - Input Section + +private struct InputSection: View { + @Binding var input: String + @FocusState private var isInputFocused: Bool + + private var detectedType: TrezorBalanceLookupView.InputType { + TrezorViewModel.detectInputType(input) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Address or Extended Public Key") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + Spacer() + + if !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + TypeBadge(type: detectedType) + } + } + + SwiftUI.TextField("Paste address or xpub...", text: $input, axis: .vertical) + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.white) + .lineLimit(3 ... 5) + .focused($isInputFocused) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + isInputFocused = false + } + } + } + + HStack(spacing: 12) { + Button(action: { + if let clipboard = UIPasteboard.general.string { + input = clipboard + } + }) { + HStack(spacing: 4) { + Image(systemName: "doc.on.clipboard") + Text("Paste") + } + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + } + + if !input.isEmpty { + Button(action: { input = "" }) { + HStack(spacing: 4) { + Image(systemName: "xmark.circle") + Text("Clear") + } + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + } + } + } + } + } +} + +// MARK: - Type Badge + +private struct TypeBadge: View { + let type: TrezorBalanceLookupView.InputType + + var body: some View { + Text(label) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(color) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(color.opacity(0.15)) + .clipShape(Capsule()) + } + + private var label: String { + switch type { + case .extendedKey: "XPUB" + case .address: "ADDRESS" + case .unknown: "UNKNOWN" + } + } + + private var color: Color { + switch type { + case .extendedKey: .blue + case .address: .green + case .unknown: .orange + } + } +} + +// MARK: - Lookup Button + +private struct LookupButton: View { + let isLoading: Bool + let isDisabled: Bool + let action: () async -> Void + + var body: some View { + Button(action: { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + Task { await action() } + }) { + HStack(spacing: 8) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } else { + Image(systemName: "magnifyingglass") + } + Text(isLoading ? "Looking up..." : "Lookup Balance & UTXOs") + } + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(isDisabled || isLoading) + .opacity(isDisabled ? 0.5 : 1.0) + } +} + +// MARK: - Account Result Section (xpub) + +private struct AccountResultSection: View { + let result: AccountInfoResult + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Account Info") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + VStack(spacing: 12) { + ResultRow(label: "Balance", value: "\(result.balance) sats") + ResultRow(label: "UTXO Count", value: "\(result.utxoCount)") + ResultRow(label: "Account Type", value: accountTypeLabel(result.accountType)) + ResultRow(label: "Derivation Path", value: result.account.path) + ResultRow(label: "Block Height", value: "\(result.blockHeight)") + } + .padding(16) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + + private func accountTypeLabel(_ type: AccountType) -> String { + switch type { + case .legacy: "Legacy (BIP44 / P2PKH)" + case .wrappedSegwit: "Wrapped SegWit (BIP49 / P2SH-P2WPKH)" + case .nativeSegwit: "Native SegWit (BIP84 / P2WPKH)" + case .taproot: "Taproot (BIP86 / P2TR)" + } + } +} + +// MARK: - Address Result Section + +private struct AddressResultSection: View { + let result: SingleAddressInfoResult + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Address Info") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + VStack(spacing: 12) { + ResultRow(label: "Address", value: result.address) + ResultRow(label: "Balance", value: "\(result.balance) sats") + ResultRow(label: "UTXOs", value: "\(result.utxos.count)") + ResultRow(label: "Transfers", value: "\(result.transfers)") + ResultRow(label: "Block Height", value: "\(result.blockHeight)") + } + .padding(16) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } +} + +// MARK: - Result Row + +private struct ResultRow: View { + let label: String + let value: String + + var body: some View { + HStack(alignment: .top) { + Text(label) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + .frame(width: 110, alignment: .leading) + + Text(value) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white) + .textSelection(.enabled) + + Spacer() + } + } +} + +// MARK: - UTXO List Section + +private struct UTXOListSection: View { + let utxos: [AccountUtxo] + + var body: some View { + if !utxos.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("UTXOs (\(utxos.count))") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + LazyVStack(spacing: 8) { + ForEach(Array(utxos.enumerated()), id: \.offset) { _, utxo in + UTXORow(utxo: utxo) + } + } + } + } + } +} + +// MARK: - UTXO Row + +private struct UTXORow: View { + let utxo: AccountUtxo + @State private var copiedTxid = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Amount + HStack { + Text("\(utxo.amount) sats") + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundColor(.white) + + Spacer() + + if utxo.confirmations > 0 { + Text("\(utxo.confirmations) conf") + .font(.system(size: 10)) + .foregroundColor(.green.opacity(0.8)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.1)) + .clipShape(Capsule()) + } else { + Text("unconfirmed") + .font(.system(size: 10)) + .foregroundColor(.orange.opacity(0.8)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.1)) + .clipShape(Capsule()) + } + } + + // Txid + HStack(spacing: 4) { + Text(truncatedTxid) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.white.opacity(0.6)) + + Text(":\(utxo.vout)") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.white.opacity(0.4)) + + Spacer() + + Button(action: { + UIPasteboard.general.string = utxo.txid + + copiedTxid = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copiedTxid = false + } + }) { + Image(systemName: copiedTxid ? "checkmark" : "doc.on.doc") + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.4)) + } + } + + // Address + if !utxo.address.isEmpty { + Text(utxo.address) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white.opacity(0.4)) + .lineLimit(1) + .truncationMode(.middle) + } + + // Path (for xpub lookups) + if !utxo.path.isEmpty { + Text(utxo.path) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white.opacity(0.3)) + } + } + .padding(12) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private var truncatedTxid: String { + let txid = utxo.txid + if txid.count > 16 { + return "\(txid.prefix(8))...\(txid.suffix(8))" + } + return txid + } +} diff --git a/Bitkit/Views/Trezor/TrezorConnectedView.swift b/Bitkit/Views/Trezor/TrezorConnectedView.swift new file mode 100644 index 00000000..7d35d0e9 --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorConnectedView.swift @@ -0,0 +1,168 @@ +import BitkitCore +import SwiftUI + +/// View displayed when connected to a Trezor device. +/// Uses expandable sections instead of navigation to separate screens. +struct TrezorConnectedView: View { + @Environment(TrezorViewModel.self) private var trezor + @State private var isAddressExpanded = false + @State private var isSignMessageExpanded = false + @State private var isPublicKeyExpanded = false + @State private var isBalanceLookupExpanded = false + @State private var isDeviceInfoExpanded = false + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // Device card + DeviceInfoCard( + device: trezor.connectedDevice, + features: trezor.deviceFeatures + ) + + // Expandable sections + VStack(spacing: 12) { + TrezorExpandableSection( + title: "Get Address", + icon: "qrcode", + description: "Generate a receiving address", + isExpanded: $isAddressExpanded + ) { + TrezorAddressContent() + } + + TrezorExpandableSection( + title: "Sign Message", + icon: "signature", + description: "Sign a message with your Trezor", + isExpanded: $isSignMessageExpanded + ) { + TrezorSignMessageContent() + } + + TrezorExpandableSection( + title: "Public Key", + icon: "key", + description: "Get xpub and public key", + isExpanded: $isPublicKeyExpanded + ) { + TrezorPublicKeyContent() + } + + TrezorExpandableSection( + title: "Balance Lookup", + icon: "magnifyingglass", + description: "Check balance & UTXOs for any address or xpub", + isExpanded: $isBalanceLookupExpanded + ) { + TrezorBalanceLookupContent() + } + + TrezorExpandableSection( + title: "Device Info", + icon: "info.circle", + description: "View device details and features", + isExpanded: $isDeviceInfoExpanded + ) { + TrezorDeviceFeaturesContent() + } + } + + // Disconnect button + Button(action: { + Task { + await trezor.disconnect() + } + }) { + HStack(spacing: 8) { + Image(systemName: "eject") + Text("Disconnect") + } + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.red.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + .padding(16) + .contentShape(Rectangle()) + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } + .scrollDismissesKeyboard(.interactively) + .background(Color.black) + .navigationTitle("Trezor") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + TrezorStatusBadge( + isConnected: trezor.isConnected, + deviceName: trezor.deviceFeatures?.label + ) + .allowsHitTesting(false) + } + } + } +} + +// MARK: - Device Info Card + +private struct DeviceInfoCard: View { + let device: TrezorDeviceInfo? + let features: TrezorFeatures? + + var body: some View { + VStack(spacing: 16) { + // Icon + Image(systemName: "externaldrive.fill") + .font(.system(size: 40)) + .foregroundColor(.white) + .padding(20) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + + // Device name + VStack(spacing: 4) { + Text(features?.label ?? "Trezor") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + + if let model = features?.model { + Text("Model \(model)") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + } + } + + // Firmware version + if let major = features?.majorVersion, + let minor = features?.minorVersion, + let patch = features?.patchVersion + { + Text("Firmware: \(major).\(minor).\(patch)") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.4)) + } + } + .padding(24) + .frame(maxWidth: .infinity) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorConnectedView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + TrezorConnectedView() + } + .environment(TrezorViewModel()) + } + } +#endif diff --git a/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift new file mode 100644 index 00000000..0fc6e8aa --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorDeviceFeaturesView.swift @@ -0,0 +1,273 @@ +import BitkitCore +import SwiftUI + +/// Inline content for device features, used by expandable section. +struct TrezorDeviceFeaturesContent: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + VStack(spacing: 24) { + if let features = trezor.deviceFeatures { + FirmwareSection(features: features) + SecuritySection(features: features) + IdentifiersSection(features: features, device: trezor.connectedDevice) + ActionsSection() + } else { + NoFeaturesView() + } + } + } +} + +/// Full-screen view displaying detailed device features (used for previews) +struct TrezorDeviceFeaturesView: View { + var body: some View { + ScrollView { + TrezorDeviceFeaturesContent() + .padding(16) + } + .background(Color.black) + .navigationTitle("Device Info") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Device Header Section + +private struct DeviceHeaderSection: View { + let features: TrezorFeatures + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "externaldrive.fill") + .font(.system(size: 48)) + .foregroundColor(.white) + .padding(24) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + + VStack(spacing: 4) { + Text(features.label ?? "Trezor") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + if let model = features.model { + Text("Model \(model)") + .font(.system(size: 16)) + .foregroundColor(.white.opacity(0.6)) + } + } + } + .frame(maxWidth: .infinity) + .padding(24) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +// MARK: - Firmware Section + +private struct FirmwareSection: View { + let features: TrezorFeatures + + var body: some View { + InfoSection(title: "Firmware") { + if let major = features.majorVersion, + let minor = features.minorVersion, + let patch = features.patchVersion + { + InfoRow(label: "Version", value: "\(major).\(minor).\(patch)") + } + + if let vendor = features.vendor { + InfoRow(label: "Vendor", value: vendor) + } + } + } +} + +// MARK: - Security Section + +private struct SecuritySection: View { + let features: TrezorFeatures + + var body: some View { + InfoSection(title: "Security") { + TrezorStatusRow( + label: "PIN Protection", + isEnabled: features.pinProtection ?? false + ) + + TrezorStatusRow( + label: "Passphrase Protection", + isEnabled: features.passphraseProtection ?? false + ) + + TrezorStatusRow( + label: "Initialized", + isEnabled: features.initialized ?? false + ) + + TrezorStatusRow( + label: "Needs Backup", + isEnabled: features.needsBackup ?? false, + positiveColor: .orange + ) + } + } +} + +// MARK: - Identifiers Section + +private struct IdentifiersSection: View { + let features: TrezorFeatures + let device: TrezorDeviceInfo? + + var body: some View { + InfoSection(title: "Device Identifiers") { + if let deviceId = features.deviceId { + InfoRow(label: "Device ID", value: deviceId, isMonospaced: true) + } + + if let path = device?.path { + InfoRow(label: "Path", value: path, isMonospaced: true) + } + } + } +} + +// MARK: - Actions Section + +private struct ActionsSection: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + VStack(spacing: 12) { + Button(action: { + Task { + await trezor.clearCredentials() + } + }) { + HStack(spacing: 8) { + Image(systemName: "trash") + Text("Clear Stored Credentials") + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.orange) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + Text("This will require re-pairing via Bluetooth on next connection") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.4)) + .multilineTextAlignment(.center) + } + } +} + +// MARK: - No Features View + +private struct NoFeaturesView: View { + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("Device features not available") + .font(.system(size: 16)) + .foregroundColor(.white.opacity(0.6)) + } + .padding(48) + } +} + +// MARK: - Info Section Container + +private struct InfoSection: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white.opacity(0.6)) + + VStack(spacing: 8) { + content + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } +} + +// MARK: - Info Row + +private struct InfoRow: View { + let label: String + let value: String + var isMonospaced: Bool = false + + var body: some View { + HStack { + Text(label) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + + Spacer() + + Text(value) + .font(.system(size: 14, design: isMonospaced ? .monospaced : .default)) + .foregroundColor(.white) + .lineLimit(1) + } + } +} + +// MARK: - Trezor Status Row + +private struct TrezorStatusRow: View { + let label: String + let isEnabled: Bool + var positiveColor: Color = .green + + var body: some View { + HStack { + Text(label) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + + Spacer() + + HStack(spacing: 6) { + Circle() + .fill(isEnabled ? positiveColor : Color.white.opacity(0.3)) + .frame(width: 8, height: 8) + + Text(isEnabled ? "Yes" : "No") + .font(.system(size: 14)) + .foregroundColor(isEnabled ? positiveColor : .white.opacity(0.6)) + } + } + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorDeviceFeaturesView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + TrezorDeviceFeaturesView() + } + .environment(TrezorViewModel()) + } + } +#endif diff --git a/Bitkit/Views/Trezor/TrezorDeviceListView.swift b/Bitkit/Views/Trezor/TrezorDeviceListView.swift new file mode 100644 index 00000000..64a0c466 --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorDeviceListView.swift @@ -0,0 +1,314 @@ +import BitkitCore +import CoreBluetooth +import SwiftUI + +/// View displaying discovered Trezor devices +struct TrezorDeviceListView: View { + @Environment(TrezorViewModel.self) private var trezor + @State private var connectingDevicePath: String? + + /// Scanned devices that are NOT already in the known devices list + private var nearbyDevices: [TrezorDeviceInfo] { + let knownIds = Set(trezor.knownDevices.map(\.id)) + return trezor.devices.filter { !knownIds.contains($0.id) } + } + + var body: some View { + VStack(spacing: 0) { + // Content + ScrollView { + VStack(spacing: 24) { + // Bluetooth status (don't show during initial .unknown state) + if trezor.bluetoothState != .poweredOn, trezor.bluetoothState != .unknown { + BluetoothStatusCard(state: trezor.bluetoothState) + } + + // Auto-reconnect indicator + if trezor.isAutoReconnecting, let status = trezor.autoReconnectStatus { + AutoReconnectIndicator(status: status) + } + + // Scanning indicator + if trezor.isScanning, !trezor.isAutoReconnecting { + ScanningIndicator() + } + + // Known devices section + if !trezor.knownDevices.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("My Devices") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + ForEach(trezor.knownDevices) { device in + KnownDeviceRow( + device: device, + isConnecting: connectingDevicePath == device.path + ) { + connectToKnownDevice(device) + } onForget: { + Task { + await trezor.forgetDevice(id: device.id) + } + } + } + } + } + + // Nearby (new) devices section + if !nearbyDevices.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Nearby Devices") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + ForEach(nearbyDevices, id: \.path) { device in + TrezorDeviceRow( + device: device, + isConnecting: connectingDevicePath == device.path + ) { + connectToDevice(device) + } + } + } + } + + // Empty state + if !trezor.isScanning, !trezor.isAutoReconnecting, + trezor.knownDevices.isEmpty, trezor.devices.isEmpty + { + TrezorEmptyStateView() + } + + // Error display + if let error = trezor.error { + ErrorCard(message: error) + } + } + .padding(16) + } + + // Bottom action button + if !trezor.isScanning, !trezor.isAutoReconnecting, + trezor.bluetoothState == .poweredOn || trezor.bluetoothState == .unknown + { + Button(action: { + Task { + await trezor.startScan() + } + }) { + HStack(spacing: 8) { + Image(systemName: "antenna.radiowaves.left.and.right") + Text(trezor.devices.isEmpty ? "Scan for Devices" : "Scan Again") + } + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(16) + } + } + .background(Color.black) + .navigationTitle("Connect Trezor") + .navigationBarTitleDisplayMode(.inline) + .task { + trezor.loadKnownDevices() + + // Wait briefly for BLE state to settle if still unknown + // (CBCentralManager fires centralManagerDidUpdateState async after creation) + if trezor.bluetoothState == .unknown { + for _ in 0 ..< 10 { + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + if trezor.bluetoothState != .unknown { break } + } + } + + guard trezor.bluetoothState == .poweredOn else { return } + + if !trezor.knownDevices.isEmpty { + await trezor.autoReconnect() + } else if trezor.devices.isEmpty { + await trezor.startScan() + } + } + .onDisappear { + trezor.stopScan() + } + } + + private func connectToDevice(_ device: TrezorDeviceInfo) { + connectingDevicePath = device.path + + Task { + await trezor.connect(device: device) + connectingDevicePath = nil + } + } + + private func connectToKnownDevice(_ knownDevice: TrezorKnownDevice) { + connectingDevicePath = knownDevice.path + + Task { + // Check if this device was found in the last scan + if let scanned = trezor.devices.first(where: { $0.id == knownDevice.id }) { + await trezor.connect(device: scanned) + } else { + // Need to scan first to find the device + await trezor.startScan(clearExisting: false) + if let scanned = trezor.devices.first(where: { $0.id == knownDevice.id }) { + await trezor.connect(device: scanned) + } else { + trezor.error = "Device not found nearby. Make sure your Trezor is turned on." + } + } + connectingDevicePath = nil + } + } +} + +// MARK: - Subviews + +private struct AutoReconnectIndicator: View { + let status: String + + var body: some View { + HStack(spacing: 12) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + + Text(status) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + } + .padding(16) + } +} + +private struct BluetoothStatusCard: View { + let state: CBManagerState + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "bluetooth") + .font(.system(size: 32)) + .foregroundColor(.orange) + + Text(statusTitle) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + + Text(statusDescription) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + .multilineTextAlignment(.center) + } + .padding(24) + .frame(maxWidth: .infinity) + .background(Color.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + private var statusTitle: String { + switch state { + case .poweredOff: + return "Bluetooth is Off" + case .unauthorized: + return "Bluetooth Unauthorized" + case .unsupported: + return "Bluetooth Unsupported" + default: + return "Bluetooth Unavailable" + } + } + + private var statusDescription: String { + switch state { + case .poweredOff: + return "Please enable Bluetooth in Settings to connect to your Trezor." + case .unauthorized: + return "Bitkit needs Bluetooth permission to connect to your Trezor. Please enable it in Settings." + case .unsupported: + return "This device does not support Bluetooth Low Energy." + default: + return "Bluetooth is not available. Please check your device settings." + } + } +} + +private struct ScanningIndicator: View { + var body: some View { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + + Text("Scanning for Trezor devices...") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + + Text("Make sure your Trezor is turned on with Bluetooth enabled") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.4)) + .multilineTextAlignment(.center) + } + .padding(32) + } +} + +private struct TrezorEmptyStateView: View { + var body: some View { + VStack(spacing: 20) { + Image(systemName: "externaldrive") + .font(.system(size: 48)) + .foregroundColor(.white.opacity(0.3)) + + VStack(spacing: 8) { + Text("No Devices Found") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + + Text("Make sure your Trezor is turned on and Bluetooth is enabled.") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + .multilineTextAlignment(.center) + } + } + .padding(32) + } +} + +private struct ErrorCard: View { + let message: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + + Text(message) + .font(.system(size: 14)) + .foregroundColor(.white) + + Spacer() + } + .padding(16) + .background(Color.red.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorDeviceListView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + TrezorDeviceListView() + } + .environment(TrezorViewModel()) + } + } +#endif diff --git a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift new file mode 100644 index 00000000..0842b306 --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +/// Inline content for public key retrieval, used by expandable section. +struct TrezorPublicKeyContent: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + @Bindable var trezor = trezor + VStack(spacing: 24) { + // Account path + VStack(alignment: .leading, spacing: 12) { + Text("Account Path") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + SwiftUI.TextField("m/84'/0'/0'", text: $trezor.publicKeyPath) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(.white) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Show on device toggle + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Show on Trezor") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + Text("Display public key on device for verification") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + } + + Spacer() + + Toggle("", isOn: $trezor.showPublicKeyOnDevice) + .labelsHidden() + .tint(.green) + } + .padding(16) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + // Get Public Key button + Button(action: { + Task { + await trezor.getPublicKey(showOnDevice: trezor.showPublicKeyOnDevice) + } + }) { + HStack(spacing: 8) { + if trezor.isOperating { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } else { + Image(systemName: "key") + } + Text("Get Public Key") + } + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(trezor.isOperating) + + // Results + if let xpub = trezor.xpub { + CopyableField(label: "Extended Public Key (xpub)", value: xpub) + } + + if let pubKey = trezor.publicKeyHex { + CopyableField(label: "Compressed Public Key", value: pubKey) + } + + // Error + if let error = trezor.error { + TrezorErrorBanner(message: error) + } + } + } +} + +/// Full-screen view for retrieving xpub and public key from Trezor (used for previews) +struct TrezorPublicKeyView: View { + var body: some View { + ScrollView { + TrezorPublicKeyContent() + .padding(16) + } + .scrollDismissesKeyboard(.interactively) + .background(Color.black) + .navigationTitle("Public Key") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Copyable Field + +private struct CopyableField: View { + let label: String + let value: String + @State private var copied = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + + Text(value) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Button(action: { + UIPasteboard.general.string = value + + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copied = false + } + }) { + HStack(spacing: 4) { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + Text(copied ? "Copied!" : "Copy") + } + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + } + } + .padding(16) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorPublicKeyView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + TrezorPublicKeyView() + } + .environment(TrezorViewModel()) + } + } +#endif diff --git a/Bitkit/Views/Trezor/TrezorRootView.swift b/Bitkit/Views/Trezor/TrezorRootView.swift new file mode 100644 index 00000000..d7685c3f --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorRootView.swift @@ -0,0 +1,523 @@ +import BitkitCore +import SwiftUI + +/// Root view for Trezor integration +/// Contains navigation and overlay sheets for PIN/pairing dialogs. +/// The body avoids direct ViewModel access — all @Environment reads +/// are isolated in child views so this view doesn't re-render on every property change. +struct TrezorRootView: View { + var body: some View { + VStack(spacing: 0) { + NetworkSelectorRow() + + ZStack(alignment: .bottom) { + TrezorContentSwitcher() + .frame(maxHeight: .infinity) + .padding(.bottom, 40) + + TrezorDebugLogWrapper() + } + } + .modifier(TrezorDialogsModifier()) + } +} + +// MARK: - Content Switcher + +/// Isolates the connected/disconnected toggle so only this view +/// re-renders when connection state changes. +private struct TrezorContentSwitcher: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + Group { + if trezor.isConnected { + TrezorConnectedView() + } else { + TrezorDeviceListView() + } + } + .animation(.easeInOut(duration: 0.25), value: trezor.isConnected) + .task { + trezor.setup() + } + } +} + +// MARK: - Debug Log Wrapper + +/// Isolates the debug log panel's ViewModel binding. +private struct TrezorDebugLogWrapper: View { + @Environment(TrezorViewModel.self) private var trezor + + var body: some View { + @Bindable var trezor = trezor + TrezorDebugLogPanel( + isExpanded: $trezor.showDebugLog + ) + } +} + +// MARK: - Dialogs Modifier + +/// Groups all sheet and overlay presentations that depend on ViewModel state, +/// keeping TrezorRootView's body free of @Environment access. +private struct TrezorDialogsModifier: ViewModifier { + @Environment(TrezorViewModel.self) private var trezor + + func body(content: Content) -> some View { + @Bindable var trezor = trezor + content + .sheet(isPresented: $trezor.showPinEntry) { + TrezorPinEntrySheet() + } + .sheet(isPresented: $trezor.showPairingCode) { + TrezorPairingCodeSheet() + } + .sheet(isPresented: $trezor.showPassphraseEntry) { + TrezorPassphraseSheet() + } + .overlay { + if trezor.showConfirmOnDevice { + TrezorConfirmOnDeviceOverlay( + message: trezor.confirmMessage, + onCancel: { + trezor.dismissConfirmOnDevice() + } + ) + } + } + } +} + +// MARK: - Network Selector + +private struct NetworkSelectorRow: View { + @Environment(TrezorViewModel.self) private var trezor + + private let networks: [(TrezorCoinType, String)] = [ + (.bitcoin, "Bitcoin"), + (.testnet, "Testnet"), + (.regtest, "Regtest"), + ] + + var body: some View { + VStack(spacing: 8) { + Text("Dashboard Network") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white.opacity(0.4)) + + HStack(spacing: 8) { + ForEach(Array(networks.enumerated()), id: \.offset) { _, item in + let (network, label) = item + Button(action: { trezor.setSelectedNetwork(network) }) { + Text(label) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(trezor.selectedNetwork == network ? .white : .white.opacity(0.5)) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(trezor.selectedNetwork == network ? Color.white.opacity(0.2) : Color.white.opacity(0.05)) + .clipShape(Capsule()) + } + } + } + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Color.white.opacity(0.02)) + } +} + +// MARK: - PIN Entry Sheet + +struct TrezorPinEntrySheet: View { + @Environment(TrezorViewModel.self) private var trezor + @Environment(\.dismiss) private var dismiss + @State private var pin: String = "" + + var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 8) { + Text("Enter PIN") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + Text("Look at your Trezor and tap the positions where you see your PIN digits") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + // PIN Pad + TrezorPinPad(pin: $pin) + + Spacer() + + // Buttons + HStack(spacing: 16) { + Button(action: { + trezor.cancelPin() + dismiss() + }) { + Text("Cancel") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + Button(action: { + trezor.submitPin(pin) + dismiss() + }) { + Text("Confirm") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(pin.isEmpty) + .opacity(pin.isEmpty ? 0.5 : 1.0) + } + .padding(.horizontal, 16) + } + .padding(.top, 32) + .padding(.bottom, 16) + .background(Color.black) + .interactiveDismissDisabled() + } +} + +// MARK: - Pairing Code Sheet + +struct TrezorPairingCodeSheet: View { + @Environment(TrezorViewModel.self) private var trezor + @Environment(\.dismiss) private var dismiss + @State private var code: String = "" + @State private var hasSubmitted = false + + private let digitCount = 6 + + var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 8) { + Text("Enter Pairing Code") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + Text("Enter the 6-digit code shown on your Trezor screen") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + // Code input + TrezorPairingCodeInput(code: $code) + + Spacer() + + // Buttons + HStack(spacing: 16) { + Button(action: { + trezor.cancelPairingCode() + dismiss() + }) { + Text("Cancel") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + Button(action: { + guard !hasSubmitted else { return } + hasSubmitted = true + trezor.submitPairingCode(code) + dismiss() + }) { + Text("Confirm") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(code.count < digitCount || hasSubmitted) + .opacity(code.count < digitCount || hasSubmitted ? 0.5 : 1.0) + } + .padding(.horizontal, 16) + } + .padding(.top, 32) + .padding(.bottom, 16) + .background(Color.black) + .interactiveDismissDisabled() + .onChange(of: code) { newValue in + if newValue.count == digitCount { + guard !hasSubmitted else { return } + hasSubmitted = true + trezor.submitPairingCode(newValue) + dismiss() + } + } + } +} + +// MARK: - Passphrase Sheet + +struct TrezorPassphraseSheet: View { + @Environment(TrezorViewModel.self) private var trezor + @Environment(\.dismiss) private var dismiss + @State private var passphrase: String = "" + @State private var confirmPassphrase: String = "" + @State private var showPassphrase: Bool = false + @FocusState private var focusedField: Field? + + enum Field { + case passphrase + case confirm + } + + private var isValid: Bool { + passphrase == confirmPassphrase + } + + var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 8) { + Text("Enter Passphrase") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + Text("Enter your passphrase. Leave empty if you don't use one.") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + // Passphrase fields + VStack(spacing: 16) { + SecureInputField( + placeholder: "Passphrase", + text: $passphrase, + showText: showPassphrase + ) + .focused($focusedField, equals: .passphrase) + + SecureInputField( + placeholder: "Confirm Passphrase", + text: $confirmPassphrase, + showText: showPassphrase + ) + .focused($focusedField, equals: .confirm) + + // Show/hide toggle + Button(action: { showPassphrase.toggle() }) { + HStack(spacing: 8) { + Image(systemName: showPassphrase ? "eye.slash" : "eye") + Text(showPassphrase ? "Hide" : "Show") + } + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + } + + // Mismatch warning + if !passphrase.isEmpty, !confirmPassphrase.isEmpty, !isValid { + Text("Passphrases do not match") + .font(.system(size: 14)) + .foregroundColor(.red) + } + } + .padding(.horizontal, 16) + + Spacer() + + // Buttons + HStack(spacing: 16) { + Button(action: { + trezor.cancelPassphrase() + dismiss() + }) { + Text("Cancel") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + Button(action: { + trezor.submitPassphrase(passphrase) + dismiss() + }) { + Text("Confirm") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(!isValid) + .opacity(isValid ? 1.0 : 0.5) + } + .padding(.horizontal, 16) + } + .padding(.top, 32) + .padding(.bottom, 16) + .background(Color.black) + .interactiveDismissDisabled() + .task { + focusedField = .passphrase + } + } +} + +/// Secure text field with show/hide capability +private struct SecureInputField: View { + let placeholder: String + @Binding var text: String + let showText: Bool + + var body: some View { + Group { + if showText { + SwiftUI.TextField(placeholder, text: $text) + } else { + SecureField(placeholder, text: $text) + } + } + .font(.system(size: 16)) + .foregroundColor(.white) + .padding(16) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Debug Log Panel + +struct TrezorDebugLogPanel: View { + @Binding var isExpanded: Bool + private var debugLog = TrezorDebugLog.shared + + init(isExpanded: Binding) { + _isExpanded = isExpanded + } + + var body: some View { + VStack(spacing: 0) { + // Toggle bar + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + }) { + HStack(spacing: 8) { + Image(systemName: "terminal") + .font(.system(size: 12)) + + Text("Debug Log (\(debugLog.entries.count))") + .font(.system(size: 12, weight: .medium)) + + Spacer() + + Image(systemName: isExpanded ? "chevron.down" : "chevron.up") + .font(.system(size: 10)) + } + .foregroundColor(.white.opacity(0.5)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .background(Color.white.opacity(0.05)) + + // Expanded content + if isExpanded { + VStack(spacing: 8) { + // Action buttons + HStack(spacing: 12) { + Button(action: { + UIPasteboard.general.string = debugLog.copyAll() + + }) { + HStack(spacing: 4) { + Image(systemName: "doc.on.doc") + Text("Copy All") + } + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.5)) + } + + Button(action: { debugLog.clear() }) { + HStack(spacing: 4) { + Image(systemName: "trash") + Text("Clear") + } + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.5)) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 8) + + // Log entries (oldest first, auto-scrolls to newest) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(Array(debugLog.entries.enumerated()), id: \.offset) { index, entry in + Text(entry) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white.opacity(0.6)) + .id(index) + } + } + .padding(.horizontal, 16) + } + .frame(maxHeight: 300) + .onChange(of: debugLog.entries.count) { _ in + if let lastIndex = debugLog.entries.indices.last { + proxy.scrollTo(lastIndex, anchor: .bottom) + } + } + } + } + .padding(.bottom, 8) + .background(Color.black) + .transition(.opacity) + } + } + .background(Color.black) + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorRootView_Previews: PreviewProvider { + static var previews: some View { + TrezorRootView() + .environment(TrezorViewModel()) + } + } +#endif diff --git a/Bitkit/Views/Trezor/TrezorSendTransactionView.swift b/Bitkit/Views/Trezor/TrezorSendTransactionView.swift new file mode 100644 index 00000000..c654ebbc --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorSendTransactionView.swift @@ -0,0 +1,506 @@ +import BitkitCore +import SwiftUI + +/// Three-step send transaction flow: Compose → Review → Signed/Broadcast. +/// Embedded in TrezorBalanceLookupView when an xpub lookup has balance > 0. +struct SendTransactionSection: View { + @Binding var sendAddress: String + @Binding var sendAmountSats: String + @Binding var sendFeeRate: String + let isSendMax: Bool + let coinSelection: CoinSelection + let sendStep: SendStep + let isComposing: Bool + let isOperating: Bool + let isBroadcasting: Bool + let isDeviceConnected: Bool + let composeResult: ComposeResult? + let signedTxResult: TrezorSignedTx? + let broadcastTxid: String? + let sendError: String? + + let onToggleSendMax: () -> Void + let onCoinSelectionChange: (CoinSelection) -> Void + let onCompose: () -> Void + let onSign: () -> Void + let onBroadcast: () -> Void + let onBack: () -> Void + let onReset: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Send Transaction") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + switch sendStep { + case .form: + ComposeFormView( + sendAddress: $sendAddress, + sendAmountSats: $sendAmountSats, + sendFeeRate: $sendFeeRate, + isSendMax: isSendMax, + coinSelection: coinSelection, + isComposing: isComposing, + onToggleSendMax: onToggleSendMax, + onCoinSelectionChange: onCoinSelectionChange, + onCompose: onCompose + ) + + case .review: + if let composeResult { + ReviewSectionView( + result: composeResult, + isDeviceConnected: isDeviceConnected, + isSigning: isOperating, + onSign: onSign, + onBack: onBack + ) + } + + case .signed: + if let signedTxResult { + SignedResultSectionView( + signedTx: signedTxResult, + isBroadcasting: isBroadcasting, + broadcastTxid: broadcastTxid, + onBroadcast: onBroadcast, + onReset: onReset + ) + } + } + + if let sendError { + TrezorErrorBanner(message: sendError) + } + } + } +} + +// MARK: - Compose Form + +private struct ComposeFormView: View { + @Binding var sendAddress: String + @Binding var sendAmountSats: String + @Binding var sendFeeRate: String + let isSendMax: Bool + let coinSelection: CoinSelection + let isComposing: Bool + let onToggleSendMax: () -> Void + let onCoinSelectionChange: (CoinSelection) -> Void + let onCompose: () -> Void + @FocusState private var isFieldFocused: Bool + + private var isDisabled: Bool { + let addressEmpty = sendAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let amountEmpty = sendAmountSats.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return addressEmpty || (!isSendMax && amountEmpty) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Destination address + VStack(alignment: .leading, spacing: 4) { + Text("Destination address") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + + SwiftUI.TextField("Enter address...", text: $sendAddress, axis: .vertical) + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.white) + .lineLimit(3) + .focused($isFieldFocused) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Amount + MAX toggle + VStack(alignment: .leading, spacing: 4) { + Text("Amount (sats)") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + + HStack(spacing: 8) { + SwiftUI.TextField(isSendMax ? "MAX" : "Amount in sats", text: $sendAmountSats) + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.white) + .keyboardType(.numberPad) + .focused($isFieldFocused) + .disabled(isSendMax) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Button(action: onToggleSendMax) { + Text("MAX") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(isSendMax ? .blue : .white.opacity(0.4)) + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background((isSendMax ? Color.blue : Color.white.opacity(0.3)).opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + // Fee rate + VStack(alignment: .leading, spacing: 4) { + Text("Fee rate (sat/vB)") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + + SwiftUI.TextField("Fee rate", text: $sendFeeRate) + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.white) + .keyboardType(.numberPad) + .focused($isFieldFocused) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Coin selection strategy + VStack(alignment: .leading, spacing: 6) { + Text("Coin Selection") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + + CoinSelectionPicker( + selected: coinSelection, + onChange: onCoinSelectionChange + ) + } + + // Compose button + Button(action: { + isFieldFocused = false + onCompose() + }) { + HStack(spacing: 8) { + if isComposing { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } + Text(isComposing ? "Composing..." : "Compose Transaction") + } + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(isDisabled || isComposing) + .opacity(isDisabled || isComposing ? 0.5 : 1.0) + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + isFieldFocused = false + } + } + } + } +} + +// MARK: - Coin Selection Picker + +private struct CoinSelectionPicker: View { + let selected: CoinSelection + let onChange: (CoinSelection) -> Void + + private let strategies: [(CoinSelection, String)] = [ + (.branchAndBound, "Branch & Bound"), + (.largestFirst, "Largest First"), + (.oldestFirst, "Oldest First"), + ] + + var body: some View { + HStack(spacing: 8) { + ForEach(strategies, id: \.0) { strategy, label in + let isSelected = strategy == selected + let color: Color = isSelected ? .blue : .white.opacity(0.3) + + Button(action: { onChange(strategy) }) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + } +} + +// MARK: - Review Section + +private struct ReviewSectionView: View { + let result: ComposeResult + let isDeviceConnected: Bool + let isSigning: Bool + let onSign: () -> Void + let onBack: () -> Void + + var body: some View { + if case let .success(psbt, fee, feeRate, totalSpent) = result { + VStack(alignment: .leading, spacing: 12) { + // Summary card + VStack(spacing: 8) { + SendInfoRow(label: "Total Spent", value: "\(totalSpent) sats") + SendInfoRow(label: "Fee", value: "\(fee) sats") + SendInfoRow(label: "Fee Rate", value: String(format: "%.1f sat/vB", feeRate)) + } + .padding(12) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + // PSBT preview + Text("PSBT (Base64)") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + + PSBTPreview(psbt: psbt) + + // Action buttons + HStack(spacing: 12) { + Button(action: onBack) { + Text("Back") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + Button(action: onSign) { + HStack(spacing: 6) { + if isSigning { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } + Text(isSigning ? "Signing..." : "Sign with Trezor") + } + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .disabled(isSigning || !isDeviceConnected) + .opacity(isSigning || !isDeviceConnected ? 0.5 : 1.0) + } + + if !isDeviceConnected { + Text("Connect a Trezor device to sign") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.4)) + } + } + } + } +} + +// MARK: - PSBT Preview + +private struct PSBTPreview: View { + let psbt: String + @State private var copied = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + Text(psbt.prefix(200) + (psbt.count > 200 ? "..." : "")) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .textSelection(.enabled) + .lineSpacing(2) + + Spacer(minLength: 0) + + Button(action: { + UIPasteboard.general.string = psbt + + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copied = false + } + }) { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 12)) + .foregroundColor(.blue) + } + } + + Text("\(psbt.count) characters") + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.3)) + } + .padding(12) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +// MARK: - Signed Result Section + +private struct SignedResultSectionView: View { + let signedTx: TrezorSignedTx + let isBroadcasting: Bool + let broadcastTxid: String? + let onBroadcast: () -> Void + let onReset: () -> Void + + @State private var copiedRawTx = false + @State private var copiedTxid = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Signature summary + VStack(spacing: 8) { + SendInfoRow(label: "Signatures", value: "\(signedTx.signatures.count)") + if let txid = signedTx.txid { + SendInfoRow(label: "TXID", value: txid) + } + } + .padding(12) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + // Raw transaction hex + Text("Raw Transaction Hex") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + Text(signedTx.serializedTx) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white) + .textSelection(.enabled) + .lineSpacing(2) + + Spacer(minLength: 0) + + Button(action: { + UIPasteboard.general.string = signedTx.serializedTx + + copiedRawTx = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copiedRawTx = false + } + }) { + Image(systemName: copiedRawTx ? "checkmark" : "doc.on.doc") + .font(.system(size: 12)) + .foregroundColor(.blue) + } + } + } + .padding(12) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + // Broadcast or result + if let broadcastTxid { + BroadcastResultCard(txid: broadcastTxid) + } else { + Button(action: onBroadcast) { + HStack(spacing: 8) { + if isBroadcasting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } + Text(isBroadcasting ? "Broadcasting..." : "Broadcast") + } + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(isBroadcasting) + .opacity(isBroadcasting ? 0.5 : 1.0) + } + + // New transaction button + Button(action: onReset) { + Text("New Transaction") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } +} + +// MARK: - Broadcast Result Card + +private struct BroadcastResultCard: View { + let txid: String + @State private var copied = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text("Broadcast TXID") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + + Button(action: { + UIPasteboard.general.string = txid + + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copied = false + } + }) { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 10)) + .foregroundColor(.blue) + } + } + + Text(txid) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.blue) + .textSelection(.enabled) + .lineSpacing(2) + } + .padding(12) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +// MARK: - Info Row Helper + +private struct SendInfoRow: View { + let label: String + let value: String + + var body: some View { + HStack(alignment: .top) { + Text(label) + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.5)) + .frame(width: 100, alignment: .leading) + + Text(value) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.white) + .textSelection(.enabled) + + Spacer() + } + } +} diff --git a/Bitkit/Views/Trezor/TrezorSignMessageView.swift b/Bitkit/Views/Trezor/TrezorSignMessageView.swift new file mode 100644 index 00000000..fefba418 --- /dev/null +++ b/Bitkit/Views/Trezor/TrezorSignMessageView.swift @@ -0,0 +1,355 @@ +import BitkitCore +import SwiftUI + +// MARK: - Sign/Verify Tab + +enum TrezorSignMessageTab: String, CaseIterable { + case sign = "Sign" + case verify = "Verify" +} + +/// Inline content for message signing, used by expandable section. +struct TrezorSignMessageContent: View { + @State private var verifyAddress: String = "" + @State private var verifySignature: String = "" + @State private var verifyMessage: String = "" + @State private var verificationResult: Bool? + @State private var selectedTab: TrezorSignMessageTab = .sign + + var body: some View { + VStack(spacing: 16) { + Picker("Mode", selection: $selectedTab) { + ForEach(TrezorSignMessageTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + + switch selectedTab { + case .sign: + SignMessageContent() + case .verify: + VerifyMessageContent( + address: $verifyAddress, + signature: $verifySignature, + message: $verifyMessage, + verificationResult: $verificationResult + ) + } + } + } +} + +/// Full-screen view for signing messages with Trezor (used for previews) +struct TrezorSignMessageView: View { + var body: some View { + ScrollView { + TrezorSignMessageContent() + .padding(16) + } + .scrollDismissesKeyboard(.immediately) + .background(Color.black) + .navigationTitle("Message Signing") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Sign Message Content + +private struct SignMessageContent: View { + @Environment(TrezorViewModel.self) private var trezor + @FocusState private var isFieldFocused: Bool + + var body: some View { + @Bindable var trezor = trezor + VStack(spacing: 24) { + // Derivation path + VStack(alignment: .leading, spacing: 8) { + Text("Derivation Path") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + SwiftUI.TextField("m/84'/0'/0'/0/0", text: $trezor.messageSigningPath) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(.white) + .focused($isFieldFocused) + .submitLabel(.next) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Message input + VStack(alignment: .leading, spacing: 8) { + Text("Message to Sign") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + SwiftUI.TextField("Enter message...", text: $trezor.messageToSign, axis: .vertical) + .font(.system(size: 14)) + .foregroundColor(.white) + .lineLimit(5 ... 10) + .focused($isFieldFocused) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Sign button + Button(action: { + isFieldFocused = false + Task { + await trezor.signMessage() + } + }) { + HStack(spacing: 8) { + if trezor.isOperating { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } else { + Image(systemName: "signature") + } + Text("Sign Message") + } + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(trezor.isOperating || trezor.messageToSign.isEmpty) + .opacity(trezor.messageToSign.isEmpty ? 0.5 : 1.0) + + // Result display + if let signedMessage = trezor.signedMessage { + SignedMessageResult(response: signedMessage) + } + + // Error display + if let error = trezor.error { + TrezorErrorBanner(message: error) + } + } + .padding(16) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + isFieldFocused = false + } + } + } + } +} + +// MARK: - Verify Message Content + +private struct VerifyMessageContent: View { + @Environment(TrezorViewModel.self) private var trezor + @Binding var address: String + @Binding var signature: String + @Binding var message: String + @Binding var verificationResult: Bool? + @FocusState private var isFieldFocused: Bool + + var body: some View { + VStack(spacing: 24) { + // Address input + VStack(alignment: .leading, spacing: 8) { + Text("Signing Address") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + SwiftUI.TextField("bc1q...", text: $address) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(.white) + .focused($isFieldFocused) + .submitLabel(.next) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Signature input + VStack(alignment: .leading, spacing: 8) { + Text("Signature (Base64)") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + SwiftUI.TextField("Signature", text: $signature) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white) + .focused($isFieldFocused) + .submitLabel(.next) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Message input + VStack(alignment: .leading, spacing: 8) { + Text("Original Message") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + + SwiftUI.TextField("Enter message...", text: $message, axis: .vertical) + .font(.system(size: 14)) + .foregroundColor(.white) + .lineLimit(4 ... 8) + .focused($isFieldFocused) + .padding(12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Verify button + Button(action: { + isFieldFocused = false + Task { + verificationResult = await trezor.verifyMessage( + address: address, + signature: signature, + message: message + ) + } + }) { + HStack(spacing: 8) { + if trezor.isOperating { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + } else { + Image(systemName: "checkmark.shield") + } + Text("Verify Signature") + } + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(trezor.isOperating || !isFormValid) + .opacity(isFormValid ? 1.0 : 0.5) + + // Verification result + if let result = verificationResult { + VerificationResultBanner(isValid: result) + } + + // Error display + if let error = trezor.error { + TrezorErrorBanner(message: error) + } + } + .padding(16) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + isFieldFocused = false + } + } + } + } + + private var isFormValid: Bool { + !address.isEmpty && !signature.isEmpty && !message.isEmpty + } +} + +// MARK: - Signed Message Result + +private struct SignedMessageResult: View { + let response: TrezorSignedMessageResponse + @State private var copiedSignature = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Signature") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + + // Address + VStack(alignment: .leading, spacing: 4) { + Text("Address:") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + + Text(response.address) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.white) + } + + // Signature + VStack(alignment: .leading, spacing: 4) { + Text("Signature (Base64):") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + + Text(response.signature) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white) + .lineLimit(3) + } + + // Copy button + Button(action: { + UIPasteboard.general.string = response.signature + copiedSignature = true + + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copiedSignature = false + } + }) { + HStack(spacing: 8) { + Image(systemName: copiedSignature ? "checkmark" : "doc.on.doc") + Text(copiedSignature ? "Copied!" : "Copy Signature") + } + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.8)) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.green.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Verification Result Banner + +private struct VerificationResultBanner: View { + let isValid: Bool + + var body: some View { + HStack(spacing: 12) { + Image(systemName: isValid ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(isValid ? .green : .red) + + Text(isValid ? "Signature is valid" : "Signature is invalid") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + + Spacer() + } + .padding(16) + .background((isValid ? Color.green : Color.red).opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Preview + +#if DEBUG + struct TrezorSignMessageView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + TrezorSignMessageView() + } + .environment(TrezorViewModel()) + } + } +#endif