Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1200,7 +1200,7 @@
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
kind = exactVersion;
version = 0.1.64;
version = 0.1.66;
};
};
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "96FE1F7B2C2DE6AC006D0C8B"
Expand Down
37 changes: 29 additions & 8 deletions Bitkit/Components/Trezor/TrezorPinPad.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ struct TrezorPinPad: View {
/// Current PIN being entered
@Binding var pin: String

/// Maximum PIN length
var maxLength: Int = 9
/// Maximum PIN length. Trezor PINs can be up to 50 digits.
var maxLength: Int = 50

/// Number of entered-digit dots to render per row before wrapping.
private let dotsPerRow = 9

/// PIN pad layout (positions map to device keypad)
/// The Trezor shows scrambled numbers, we show only position dots
Expand All @@ -19,12 +22,30 @@ struct TrezorPinPad: View {

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)
// PIN display — one dot per entered digit, wrapping across rows so long
// PINs (Trezor allows up to 50 digits) don't overflow a single line.
VStack(spacing: 8) {
if pin.isEmpty {
// Placeholder row so the layout doesn't collapse before entry.
HStack(spacing: 12) {
ForEach(0 ..< dotsPerRow, id: \.self) { _ in
Circle()
.fill(Color.white.opacity(0.3))
.frame(width: 12, height: 12)
}
}
} else {
let rowCount = (pin.count + dotsPerRow - 1) / dotsPerRow
ForEach(0 ..< rowCount, id: \.self) { row in
let dotsInRow = min(dotsPerRow, pin.count - row * dotsPerRow)
HStack(spacing: 12) {
ForEach(0 ..< dotsInRow, id: \.self) { _ in
Circle()
.fill(Color.white)
.frame(width: 12, height: 12)
}
}
}
}
}
.padding(.bottom, 24)
Expand Down
39 changes: 39 additions & 0 deletions Bitkit/Services/Trezor/TrezorEventListener.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import BitkitCore
import Foundation

/// Bridges bitkit-core's `EventListener` callback (invoked on a background thread by the
/// Rust watcher loop) onto the main actor so the ViewModel can update `@Observable` state.
///
/// Mirrors bitkit-android's `eventBridge` in `TrezorRepo`.
final class TrezorEventListener: EventListener, @unchecked Sendable {
/// Forwards `(watcherId, event)` to a consumer on the main actor.
private let onEventHandler: @MainActor (String, WatcherEvent) -> Void

init(onEvent: @escaping @MainActor (String, WatcherEvent) -> Void) {
onEventHandler = onEvent
}

func onEvent(watcherId: String, event: WatcherEvent) {
let handler = onEventHandler
Task { @MainActor in
TrezorDebugLog.shared.log("[WATCHER] [\(watcherId)] \(event.logLabel)")
handler(watcherId, event)
}
}
}

extension WatcherEvent {
/// Short label for the debug log.
var logLabel: String {
switch self {
case .transactionsChanged:
return "transactionsChanged"
case let .error(message):
return "error: \(message)"
case let .disconnected(message):
return "disconnected: \(message)"
case .reconnected:
return "reconnected"
}
}
}
33 changes: 29 additions & 4 deletions Bitkit/Services/Trezor/TrezorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,17 @@ class TrezorService {

// MARK: - Connection Management

/// Connect to a Trezor device by its ID
/// - Parameter deviceId: The device identifier (path)
/// Connect to a Trezor device by its ID, opening the wallet given by `selection`.
/// On THP devices (Safe 5/7) the passphrase is bound to the session at creation, so
/// it is supplied per-connect rather than cached between calls.
/// - Parameters:
/// - deviceId: The device identifier (path)
/// - selection: Which wallet to open (standard / hidden / on-device passphrase)
/// - Returns: Device features after successful connection
func connect(deviceId: String) async throws -> TrezorFeatures {
func connect(deviceId: String, selection: WalletSelection) async throws -> TrezorFeatures {
try await ServiceQueue.background(.core) { [self] in
ensureCallbacksRegistered()
return try await trezorConnect(deviceId: deviceId, selection: .standard)
return try await trezorConnect(deviceId: deviceId, selection: selection)
}
}

Expand Down Expand Up @@ -268,6 +272,27 @@ class TrezorService {
}
}

// MARK: - Event Watcher (No Device Required)

/// Start watching an extended public key for on-chain transaction activity.
/// Events are delivered to `listener` until the watcher is stopped.
/// Does NOT require a connected Trezor device — it subscribes to Electrum directly.
func startWatcher(params: WatcherParams, listener: EventListener) async throws {
try await ServiceQueue.background(.core) {
try await onchainStartWatcher(params: params, listener: listener)
}
}

/// Stop a specific watcher by its id.
func stopWatcher(watcherId: String) throws {
try onchainStopWatcher(watcherId: watcherId)
}

/// Stop all active watchers.
func stopAllWatchers() {
onchainStopAllWatchers()
}

// MARK: - Helpers

/// Convert TrezorCoinType to the Network enum used by onchain FFI functions
Expand Down
183 changes: 92 additions & 91 deletions Bitkit/Services/Trezor/TrezorUiHandler.swift
Original file line number Diff line number Diff line change
@@ -1,40 +1,53 @@
import BitkitCore
import Combine

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

View workflow job for this annotation

GitHub Actions / Run Integration Tests

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

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

View workflow job for this annotation

GitHub Actions / Run Tests

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

/// Which wallet to open when the device asks for a passphrase.
///
/// - `standard`: no passphrase — the default wallet.
/// - `passphraseHost`: a hidden wallet, passphrase typed on the phone.
/// - `passphraseDevice`: a hidden wallet, passphrase typed on the Trezor.
enum TrezorWalletMode {
case standard
case passphraseHost
case passphraseDevice
}

/// 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().
///
/// PIN entry still blocks the Rust calling thread until the user responds via the
/// UI (the semaphore pattern shared with TrezorTransport.getPairingCode()).
///
/// Passphrase handling follows the bitkit-android model: the user selects a wallet
/// mode up front (Standard / hidden-on-phone / hidden-on-device) and that selection
/// is bound to the THP session at connect time via `currentSelection()`. The device
/// callback `onPassphraseRequest` is answered silently from the stored mode — this is
/// what non-THP (legacy) devices use when they re-request the passphrase mid-operation.
final class TrezorUiHandler: TrezorUiCallback {
static let shared = TrezorUiHandler()

// MARK: - PIN Handling

/// Publisher to notify UI when PIN entry is needed
let needsPinPublisher = PassthroughSubject<Void, Never>()

Check warning on line 32 in Bitkit/Services/Trezor/TrezorUiHandler.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

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

Check warning on line 32 in Bitkit/Services/Trezor/TrezorUiHandler.swift

View workflow job for this annotation

GitHub Actions / Run Tests

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

private var submittedPin: String = ""

Check warning on line 34 in Bitkit/Services/Trezor/TrezorUiHandler.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

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

Check warning on line 34 in Bitkit/Services/Trezor/TrezorUiHandler.swift

View workflow job for this annotation

GitHub Actions / Run Tests

stored property 'submittedPin' of 'Sendable'-conforming class 'TrezorUiHandler' is mutable; this is an error in the Swift 6 language mode
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<Bool, Never>()
/// Timeout for PIN entry (2 minutes)
private static let timeoutSeconds: TimeInterval = 120

private var submittedPassphrase: String = ""
private var didCancelPassphrase = false
private let passphraseLock = NSLock()
private let passphraseSemaphore = DispatchSemaphore(value: 0)
// MARK: - Wallet Mode / Passphrase Selection

/// Tracks whether a passphrase request is actively blocking,
/// to prevent stale semaphore signals from dismissConfirmOnDevice().
private var isAwaitingPassphrase = false
private let awaitingLock = NSLock()
private let modeLock = NSLock()
private var walletMode: TrezorWalletMode = .standard

/// Timeout for PIN/passphrase entry (2 minutes)
private static let timeoutSeconds: TimeInterval = 120
/// Host passphrase captured when `.passphraseHost` is selected. Mirrors the value
/// bound to the THP session so legacy (non-THP) devices — which re-request the
/// passphrase mid-operation via `onPassphraseRequest` — can be answered from the
/// value the user already entered up front. Nil when not in host-passphrase mode.
private var hostPassphrase: String?

private init() {}

Expand All @@ -45,6 +58,44 @@
TrezorDebugLog.shared.log("[UI] \(message)")
}

// MARK: - Wallet Mode API

/// Set which wallet to open. The caller is responsible for resetting the device
/// session (disconnect/reconnect) so the new mode takes effect — the Trezor caches
/// the passphrase for the lifetime of a session.
///
/// `hostPassphrase` is only meaningful for `.passphraseHost` — it is the passphrase
/// the user entered on the phone up front.
func setWalletMode(_ mode: TrezorWalletMode, hostPassphrase: String = "") {
modeLock.lock()
walletMode = mode
self.hostPassphrase = mode == .passphraseHost ? hostPassphrase : nil
modeLock.unlock()
debugLog("Wallet mode set to \(mode)")
}

/// The wallet the current mode/passphrase selects, for binding to a THP session when
/// `connect` runs. Mirrors `onPassphraseRequest` so THP (bound at session creation)
/// and legacy devices (answered mid-operation) stay in lockstep from one source of
/// truth. Reconnects derive their wallet from here, so it reflects the selection until
/// the next `setWalletMode` or disconnect.
func currentSelection() -> WalletSelection {
modeLock.lock()
defer { modeLock.unlock() }

switch walletMode {
case .standard:
return .standard
case .passphraseDevice:
return .onDevice
case .passphraseHost:
if let cached = hostPassphrase, !cached.isEmpty {
return .hidden(passphrase: cached)
}
return .standard
}
}

// MARK: - TrezorUiCallback Implementation

func onPinRequest() -> String {
Expand Down Expand Up @@ -77,55 +128,37 @@
}

func onPassphraseRequest(onDevice: Bool) -> PassphraseResponse {
debugLog("onPassphraseRequest: onDevice=\(onDevice), waiting for user input...")

passphraseLock.lock()
submittedPassphrase = ""
didCancelPassphrase = false
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 .cancel
}

// Device-mandated on-device entry always wins, regardless of mode.
if onDevice {
debugLog("onPassphraseRequest(onDevice): acknowledged")
debugLog("onPassphraseRequest: on-device (device-mandated), deferring to Trezor")
return .onDevice
}

passphraseLock.lock()
let passphrase = submittedPassphrase
let wasCancelled = didCancelPassphrase
passphraseLock.unlock()

if wasCancelled {
debugLog("onPassphraseRequest: cancelled")
return .cancel
modeLock.lock()
let mode = walletMode
let cached = hostPassphrase
modeLock.unlock()

switch mode {
case .standard:
debugLog("onPassphraseRequest: standard wallet")
return .standard
case .passphraseDevice:
debugLog("onPassphraseRequest: passphrase wallet (on-device entry), deferring to Trezor")
return .onDevice
case .passphraseHost:
// Answer from the passphrase entered up front (the same value bound to the
// THP session). Empty/absent == the standard wallet.
if let cached, !cached.isEmpty {
debugLog("onPassphraseRequest: host passphrase wallet, answering with pre-entered passphrase")
return .hidden(value: cached)
}
debugLog("onPassphraseRequest: host passphrase empty, answering with standard")
return .standard
}

debugLog("onPassphraseRequest: \(passphrase.isEmpty ? "standard wallet" : "received")")
return passphrase.isEmpty ? .standard : .hidden(value: passphrase)
}

// MARK: - UI Submit/Cancel Methods
// MARK: - PIN Submit/Cancel Methods

/// Called by ViewModel when user submits PIN
func submitPin(_ pin: String) {
Expand All @@ -144,36 +177,4 @@
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 = ""
didCancelPassphrase = true
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()
}
}
Loading
Loading