diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index e5b2fe8ae..63f2bb042 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -30,6 +30,19 @@ enum Env { return trimmed?.isEmpty == false ? trimmed : nil } + private static func configValue(_ key: String) -> String? { + let envValue = ProcessInfo.processInfo.environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines) + if envValue?.isEmpty == false { + return envValue + } + return infoPlistValue(key) + } + + private static func boolConfigValue(_ key: String) -> Bool { + guard let value = configValue(key)?.lowercased() else { return false } + return ["1", "true", "yes", "y"].contains(value) + } + private static var e2eBackend: String { (infoPlistValue("E2E_BACKEND") ?? "local").lowercased() } @@ -140,6 +153,19 @@ enum Env { } } + static var trezorBridgeEnabled: Bool { + (isDebug || isE2E) && boolConfigValue("TREZOR_BRIDGE") + } + + static var trezorBridgeUrl: String { + configValue("TREZOR_BRIDGE_URL") ?? "http://127.0.0.1:21325" + } + + static var trezorElectrumUrl: String? { + guard isDebug || isE2E else { return nil } + return configValue("TREZOR_ELECTRUM_URL") + } + static var appStorageUrl: URL { // App group so files can be shared with extensions guard let documentsDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bitkit") else { diff --git a/Bitkit/Services/Trezor/TrezorBridgeTransport.swift b/Bitkit/Services/Trezor/TrezorBridgeTransport.swift new file mode 100644 index 000000000..402f4f9d9 --- /dev/null +++ b/Bitkit/Services/Trezor/TrezorBridgeTransport.swift @@ -0,0 +1,278 @@ +import BitkitCore +import Foundation + +/// Dev/E2E-only transport for talking to the Trezor Bridge exposed by bitkit-docker. +final class TrezorBridgeTransport { + static let shared = TrezorBridgeTransport() + + private static let pathPrefix = "bridge:" + private static let deviceName = "Trezor Bridge Emulator" + private static let vendorId = UInt16(0x1209) + private static let productId = UInt16(0x53C1) + private static let headerSize = 6 + private static let connectTimeout: TimeInterval = 5 + private static let readTimeout: TimeInterval = 30 + + private let decoder = JSONDecoder() + private let sessionLock = NSLock() + private var openSessions: [String: String] = [:] + private var enumeratedSessions: [String: String] = [:] + + private init() {} + + var isEnabled: Bool { + Env.trezorBridgeEnabled + } + + func isBridgeDevice(path: String) -> Bool { + isEnabled && path.hasPrefix(Self.pathPrefix) + } + + func enumerateDevices() -> [NativeDeviceInfo] { + guard isEnabled else { return [] } + + do { + let response = try post(path: "/enumerate") + let bridgeDevices = try decoder.decode([BridgeDevice].self, from: Data(response.utf8)) + + let devices = bridgeDevices.map { device in + let bridgePath = Self.toBridgePath(device.path) + sessionLock.lock() + if let session = device.session { + enumeratedSessions[bridgePath] = session + } else { + enumeratedSessions.removeValue(forKey: bridgePath) + } + sessionLock.unlock() + + return NativeDeviceInfo( + path: bridgePath, + transportType: "usb", + name: Self.deviceName, + vendorId: Self.vendorId, + productId: Self.productId + ) + } + + debugLog("enumerateDevices: \(devices.count) Bridge device(s)") + return devices + } catch { + debugLog("enumerateDevices FAILED: \(error.localizedDescription)") + return [] + } + } + + func openDevice(path: String) -> TrezorTransportWriteResult { + let rawPath = Self.rawBridgePath(path) + + sessionLock.lock() + let previousSession = openSessions.removeValue(forKey: path) ?? enumeratedSessions[path] ?? "null" + sessionLock.unlock() + + do { + let response = try post(path: "/acquire/\(Self.encode(rawPath))/\(Self.encode(previousSession))") + let bridgeSession = try decoder.decode(BridgeSession.self, from: Data(response.utf8)) + + sessionLock.lock() + openSessions[path] = bridgeSession.session + sessionLock.unlock() + + debugLog("openDevice: \(path)") + return TrezorTransportWriteResult(success: true, error: "") + } catch { + debugLog("openDevice FAILED: \(error.localizedDescription)") + return TrezorTransportWriteResult(success: false, error: error.localizedDescription) + } + } + + func closeDevice(path: String) -> TrezorTransportWriteResult { + sessionLock.lock() + let session = openSessions.removeValue(forKey: path) + sessionLock.unlock() + + guard let session else { + return TrezorTransportWriteResult(success: true, error: "") + } + + do { + _ = try post(path: "/release/\(Self.encode(session))") + debugLog("closeDevice: \(path)") + return TrezorTransportWriteResult(success: true, error: "") + } catch { + debugLog("closeDevice FAILED: \(error.localizedDescription)") + return TrezorTransportWriteResult(success: false, error: error.localizedDescription) + } + } + + func readChunk(path: String) -> TrezorTransportReadResult { + TrezorTransportReadResult(success: false, data: Data(), error: "Trezor Bridge uses callMessage for \(path)") + } + + func writeChunk(path: String, data: Data) -> TrezorTransportWriteResult { + TrezorTransportWriteResult(success: false, error: "Trezor Bridge uses callMessage for \(path) and ignored \(data.count) bytes") + } + + func callMessage(path: String, messageType: UInt16, data: Data) -> TrezorCallMessageResult { + sessionLock.lock() + let session = openSessions[path] + sessionLock.unlock() + + guard let session else { + return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: "Trezor Bridge device not open: \(path)") + } + + do { + let request = Self.encodeFrame(messageType: messageType, data: data) + let response = try post(path: "/call/\(Self.encode(session))", body: request) + return try Self.decodeFrame(response) + } catch { + debugLog("callMessage FAILED: \(error.localizedDescription)") + return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: error.localizedDescription) + } + } + + private func post(path: String, body: String? = nil) throws -> String { + guard let url = URL(string: "\(Env.trezorBridgeUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")))\(path)") else { + throw TrezorBridgeTransportError.invalidUrl + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = body == nil ? Self.connectTimeout : Self.readTimeout + request.cachePolicy = .reloadIgnoringLocalCacheData + if let body { + request.httpBody = Data(body.utf8) + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + } + + let semaphore = DispatchSemaphore(value: 0) + var result: Result<(Data, HTTPURLResponse), Error>? + + URLSession.shared.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + if let error { + result = .failure(error) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + result = .failure(TrezorBridgeTransportError.invalidResponse) + return + } + result = .success((data ?? Data(), httpResponse)) + }.resume() + + semaphore.wait() + + let (data, response) = try result?.get() ?? (Data(), HTTPURLResponse()) + let responseText = String(data: data, encoding: .utf8) ?? "" + guard 200 ..< 300 ~= response.statusCode else { + throw TrezorBridgeTransportError.httpError(statusCode: response.statusCode, body: responseText) + } + return responseText + } + + private static func encodeFrame(messageType: UInt16, data: Data) -> String { + var frame = Data() + frame.append(UInt8((messageType >> 8) & 0xFF)) + frame.append(UInt8(messageType & 0xFF)) + + let length = UInt32(data.count) + frame.append(UInt8((length >> 24) & 0xFF)) + frame.append(UInt8((length >> 16) & 0xFF)) + frame.append(UInt8((length >> 8) & 0xFF)) + frame.append(UInt8(length & 0xFF)) + frame.append(data) + + return frame.map { String(format: "%02x", $0) }.joined() + } + + private static func decodeFrame(_ hex: String) throws -> TrezorCallMessageResult { + let bytes = try Data(hexEncoded: hex.trimmingCharacters(in: .whitespacesAndNewlines)) + guard bytes.count >= headerSize else { + throw TrezorBridgeTransportError.shortResponse + } + + let messageType = (UInt16(bytes[0]) << 8) | UInt16(bytes[1]) + let length = (UInt32(bytes[2]) << 24) | (UInt32(bytes[3]) << 16) | (UInt32(bytes[4]) << 8) | UInt32(bytes[5]) + guard bytes.count >= headerSize + Int(length) else { + throw TrezorBridgeTransportError.invalidPayloadLength + } + + let payload = bytes.subdata(in: headerSize ..< headerSize + Int(length)) + return TrezorCallMessageResult(success: true, messageType: messageType, data: payload, error: "") + } + + private static func toBridgePath(_ path: String) -> String { + "\(pathPrefix)\(path)" + } + + private static func rawBridgePath(_ path: String) -> String { + String(path.dropFirst(pathPrefix.count)) + } + + private static func encode(_ value: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private func debugLog(_ message: String) { + Logger.debug(message, context: "TrezorBridgeTransport") + TrezorDebugLog.shared.log("[Bridge] \(message)") + } +} + +private struct BridgeDevice: Decodable { + let path: String + let session: String? +} + +private struct BridgeSession: Decodable { + let session: String +} + +private enum TrezorBridgeTransportError: LocalizedError { + case invalidUrl + case invalidResponse + case httpError(statusCode: Int, body: String) + case invalidHex + case shortResponse + case invalidPayloadLength + + var errorDescription: String? { + switch self { + case .invalidUrl: + return "Invalid Trezor Bridge URL" + case .invalidResponse: + return "Invalid Trezor Bridge response" + case let .httpError(statusCode, body): + return "Bridge request failed with HTTP \(statusCode): \(body)" + case .invalidHex: + return "Bridge returned invalid hex" + case .shortResponse: + return "Bridge response is shorter than the message header" + case .invalidPayloadLength: + return "Bridge response payload length exceeds available data" + } + } +} + +private extension Data { + init(hexEncoded hex: String) throws { + guard hex.count.isMultiple(of: 2) else { + throw TrezorBridgeTransportError.invalidHex + } + + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let nextIndex = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index ..< nextIndex], radix: 16) else { + throw TrezorBridgeTransportError.invalidHex + } + data.append(byte) + index = nextIndex + } + self = data + } +} diff --git a/Bitkit/Services/Trezor/TrezorTransport.swift b/Bitkit/Services/Trezor/TrezorTransport.swift index a464a77b5..35e35c4c0 100644 --- a/Bitkit/Services/Trezor/TrezorTransport.swift +++ b/Bitkit/Services/Trezor/TrezorTransport.swift @@ -9,6 +9,7 @@ final class TrezorTransport: TrezorTransportCallback { static let shared = TrezorTransport() private let bleManager = TrezorBLEManager.shared + private let bridgeTransport = TrezorBridgeTransport.shared // MARK: - Pairing Code Handling @@ -36,7 +37,7 @@ final class TrezorTransport: TrezorTransportCallback { /// Enumerate all connected/discovered Trezor devices func enumerateDevices() -> [NativeDeviceInfo] { let bleDevices = bleManager.enumerateDevices() - let devices = bleDevices.map { device in + var devices = bleDevices.map { device in NativeDeviceInfo( path: device.path, transportType: "bluetooth", @@ -45,6 +46,7 @@ final class TrezorTransport: TrezorTransportCallback { productId: nil ) } + devices.append(contentsOf: bridgeTransport.enumerateDevices()) debugLog("enumerateDevices: \(devices.count) devices") @@ -56,6 +58,10 @@ final class TrezorTransport: TrezorTransportCallback { debugLog("openDevice: \(path)") do { + if bridgeTransport.isBridgeDevice(path: path) { + return bridgeTransport.openDevice(path: path) + } + guard path.hasPrefix("ble:") else { throw TrezorTransportError.invalidPath(path) } @@ -91,6 +97,10 @@ final class TrezorTransport: TrezorTransportCallback { func closeDevice(path: String) -> TrezorTransportWriteResult { debugLog("closeDevice: \(path)") + if bridgeTransport.isBridgeDevice(path: path) { + return bridgeTransport.closeDevice(path: path) + } + guard path.hasPrefix("ble:") else { return TrezorTransportWriteResult(success: false, error: "Invalid device path: \(path)") } @@ -102,6 +112,10 @@ final class TrezorTransport: TrezorTransportCallback { /// Read a chunk of data from the device func readChunk(path: String) -> TrezorTransportReadResult { do { + if bridgeTransport.isBridgeDevice(path: path) { + return bridgeTransport.readChunk(path: path) + } + guard path.hasPrefix("ble:") else { throw TrezorTransportError.invalidPath(path) } @@ -121,6 +135,10 @@ final class TrezorTransport: TrezorTransportCallback { debugLog("writeChunk: \(data.count) bytes") do { + if bridgeTransport.isBridgeDevice(path: path) { + return bridgeTransport.writeChunk(path: path, data: data) + } + guard path.hasPrefix("ble:") else { throw TrezorTransportError.invalidPath(path) } @@ -153,12 +171,19 @@ final class TrezorTransport: TrezorTransportCallback { /// Get the chunk size for a device func getChunkSize(path: String) -> UInt32 { + if bridgeTransport.isBridgeDevice(path: path) { + return 64 + } 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? { + if bridgeTransport.isBridgeDevice(path: path) { + return bridgeTransport.callMessage(path: path, messageType: messageType, data: data) + } + // Let Rust handle the message protocol // We only provide the raw transport layer return nil @@ -262,11 +287,11 @@ final class TrezorTransport: TrezorTransportCallback { return credential } - // MARK: - Device Scanning Helpers /// Start scanning for BLE devices func startBLEScanning() { + guard !bridgeTransport.isEnabled else { return } bleManager.startScanning() } @@ -280,6 +305,9 @@ final class TrezorTransport: TrezorTransportCallback { bleManager.bluetoothState } + var isBridgeEnabled: Bool { + bridgeTransport.isEnabled + } } // MARK: - Transport Errors diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift index 5433a72c6..b816dc4b0 100644 --- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift +++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift @@ -223,6 +223,10 @@ class TrezorViewModel { TrezorBLEManager.shared.bluetoothState } + var isBridgeModeEnabled: Bool { + transport.isBridgeEnabled + } + // MARK: - Private Properties private let trezorService = TrezorService.shared @@ -292,9 +296,11 @@ class TrezorViewModel { /// 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() + if !transport.isBridgeEnabled { + // Start BLE stack early so bluetoothState is updated by the time + // TrezorDeviceListView renders (the delegate callback fires async). + TrezorBLEManager.shared.ensureStarted() + } setupCallbackSubscriptions() hasSetupSubscriptions = true } @@ -333,15 +339,17 @@ class TrezorViewModel { devices = [] } - // Start BLE scanning - transport.startBLEScanning() + if !transport.isBridgeEnabled { + // 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 + // 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() + // Stop BLE scanning before calling FFI to prevent race conditions + transport.stopBLEScanning() + } do { // Trigger FFI scan which will use our transport callbacks @@ -798,10 +806,17 @@ class TrezorViewModel { /// Get the Electrum server URL for a specific network (hardcoded per-network URLs) static func electrumUrlForNetwork(_ network: TrezorCoinType) -> String { + if network == .regtest, let trezorElectrumUrl = Env.trezorElectrumUrl { + return trezorElectrumUrl + } + 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" + case .bitcoin: + return "ssl://bitkit.to:9999" + case .testnet, .signet: + return "ssl://electrum.blockstream.info:60002" + case .regtest: + return "ssl://electrs.bitkit.stag0.blocktank.to:9999" } } diff --git a/Bitkit/Views/Trezor/TrezorDeviceListView.swift b/Bitkit/Views/Trezor/TrezorDeviceListView.swift index 64a0c4662..807152db0 100644 --- a/Bitkit/Views/Trezor/TrezorDeviceListView.swift +++ b/Bitkit/Views/Trezor/TrezorDeviceListView.swift @@ -19,7 +19,7 @@ struct TrezorDeviceListView: View { ScrollView { VStack(spacing: 24) { // Bluetooth status (don't show during initial .unknown state) - if trezor.bluetoothState != .poweredOn, trezor.bluetoothState != .unknown { + if !trezor.isBridgeModeEnabled, trezor.bluetoothState != .poweredOn, trezor.bluetoothState != .unknown { BluetoothStatusCard(state: trezor.bluetoothState) } @@ -90,7 +90,7 @@ struct TrezorDeviceListView: View { // Bottom action button if !trezor.isScanning, !trezor.isAutoReconnecting, - trezor.bluetoothState == .poweredOn || trezor.bluetoothState == .unknown + trezor.isBridgeModeEnabled || trezor.bluetoothState == .poweredOn || trezor.bluetoothState == .unknown { Button(action: { Task { @@ -126,7 +126,7 @@ struct TrezorDeviceListView: View { } } - guard trezor.bluetoothState == .poweredOn else { return } + guard trezor.isBridgeModeEnabled || trezor.bluetoothState == .poweredOn else { return } if !trezor.knownDevices.isEmpty { await trezor.autoReconnect()