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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions bitcoin_usb/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from hwilib.devices.trezor import TrezorClient
from hwilib.hwwclient import HardwareWalletClient
from hwilib.psbt import PSBT
from PyQt6.QtCore import QEventLoop, QObject, Qt, QThread, pyqtSignal
from PyQt6.QtCore import QCoreApplication, QEventLoop, QObject, Qt, QThread, pyqtSignal
from PyQt6.QtWidgets import (
QDialog,
QDialogButtonBox,
Expand All @@ -29,7 +29,7 @@
from bitcoin_usb.dialogs import Worker
from bitcoin_usb.i18n import translate
from bitcoin_usb.jade_ble_client import JadeBleClient
from bitcoin_usb.util import run_device_task, run_script
from bitcoin_usb.util import run_script

from .address_types import (
AddressType,
Expand Down Expand Up @@ -350,8 +350,7 @@ def _init_client(self):
def __enter__(self):
self.lock.acquire()
try:
# _init_client is a synchronous function; we just use the common runner
run_device_task(loop_in_thread=self.loop_in_thread, task=self._init_client)
self._init_client()
return self
except Exception:
self.lock.release()
Expand All @@ -374,11 +373,18 @@ def get_fingerprint(self) -> str:
return self.client.get_master_fingerprint().hex()

def get_xpubs(self) -> dict[AddressType, str]:
xpubs = {}
xpubs: dict[AddressType, str] = {}
for address_type in get_all_address_types():
xpubs[address_type] = self.get_xpub(address_type.key_origin(self.network))
self._process_pending_gui_events()
return xpubs

@staticmethod
def _process_pending_gui_events() -> None:
if QCoreApplication.instance() is None:
return
QCoreApplication.processEvents()

def get_xpub(self, key_origin: str) -> str:
assert self.client
return self.client.get_pubkey_at_path(key_origin).to_string()
Expand Down
39 changes: 19 additions & 20 deletions bitcoin_usb/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,35 +106,34 @@ def __init__(
self._layout.addWidget(self.devices_group, stretch=1)

self.actions_bar = QDialogButtonBox(self)
self.usb_scan_button = self.actions_bar.addButton(
self.tr("Scan USB devices"), QDialogButtonBox.ButtonRole.ActionRole
)
self.usb_scan_button = QPushButton(self.tr("Scan USB devices"), self)
self.actions_bar.addButton(self.usb_scan_button, QDialogButtonBox.ButtonRole.ActionRole)
self.usb_scan_button.setIcon(self.usb_icon)
self.usb_scan_button.clicked.connect(self.scan_usb_devices)
self.usb_scan_button.setAutoDefault(True)
self.usb_scan_button.setDefault(True)

self.bluetooth_scan_button: QPushButton | None = None
if self.bluetooth_scan_callback:
self.bluetooth_scan_button = self.actions_bar.addButton(
self.tr("Scan Bluetooth devices"), QDialogButtonBox.ButtonRole.ActionRole
)
self.bluetooth_scan_button.setIcon(self.bluetooth_icon)
self.bluetooth_scan_button.clicked.connect(self.scan_for_bluetooth_devices)
self.bluetooth_scan_button.setAutoDefault(False)

self.install_udev_button: QPushButton | None = None
if self.install_udev_callback and sys.platform.startswith("linux"):
self.install_udev_button = self.actions_bar.addButton(
self.tr("Install udev rules"), QDialogButtonBox.ButtonRole.ActionRole
)
self.bluetooth_scan_button = QPushButton(self.tr("Scan Bluetooth devices"), self)
if not self.bluetooth_scan_callback:
self.bluetooth_scan_button.setHidden(True)
self.actions_bar.addButton(self.bluetooth_scan_button, QDialogButtonBox.ButtonRole.ActionRole)
self.bluetooth_scan_button.setIcon(self.bluetooth_icon)
self.bluetooth_scan_button.clicked.connect(self.scan_for_bluetooth_devices)
self.bluetooth_scan_button.setAutoDefault(False)

self.install_udev_button = QPushButton(self.tr("Install udev rules"), self)
if not self.install_udev_callback and sys.platform.startswith("linux"):
self.install_udev_button.setHidden(True)
elif self.install_udev_callback:
self.install_udev_button.clicked.connect(self.install_udev_callback)
self.install_udev_button.setAutoDefault(False)
self.install_udev_button.setVisible(False)
self.actions_bar.addButton(self.install_udev_button, QDialogButtonBox.ButtonRole.ActionRole)
self.install_udev_button.setAutoDefault(False)
self.install_udev_button.setVisible(False)

self.cancel_button = self.actions_bar.addButton(QDialogButtonBox.StandardButton.Cancel)
self.actions_bar.rejected.connect(self.reject)
self.cancel_button.setAutoDefault(False)
if self.cancel_button:
self.cancel_button.setAutoDefault(False)
self._layout.addWidget(self.actions_bar)

# ensure the dialog has its “natural” size
Expand Down
84 changes: 84 additions & 0 deletions bitcoin_usb/jade_ble_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from contextvars import ContextVar, Token
from typing import Any

import aioitertools
import semver
from bleak import BleakScanner
from hwilib.common import Chain
Expand All @@ -26,6 +27,7 @@
DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS = 6.0
DEFAULT_BLE_CONNECT_TIMEOUT_SECONDS = 15.0
DEFAULT_BLE_GATT_OPERATION_TIMEOUT_SECONDS = 10.0
DEFAULT_BLE_IO_TIMEOUT_SECONDS = 60.0
_IS_BT_DEVICE_PATCHED = False
_ORIGINAL_JADEPY_SUBPROCESS_RUN = jade_ble_module.subprocess.run
# Temporary per-call channel to pass a preferred BLE MAC address into the custom
Expand Down Expand Up @@ -144,6 +146,7 @@ def __init__(
self.write_task: asyncio.Task[Any] | None = None
self.connect_timeout_seconds = DEFAULT_BLE_CONNECT_TIMEOUT_SECONDS
self.gatt_operation_timeout_seconds = DEFAULT_BLE_GATT_OPERATION_TIMEOUT_SECONDS
self.io_timeout_seconds = DEFAULT_BLE_IO_TIMEOUT_SECONDS

async def _await_ble_operation(
self,
Expand Down Expand Up @@ -225,6 +228,7 @@ def _disconnection_handler(client: Any) -> None:
attempts_remaining = 5
client = None
needs_set_disconnection_callback = False
attempted_windows_unpair = False
# Bleak connect can fail transiently; retry a few times before giving up.
while not connected:
try:
Expand All @@ -247,6 +251,15 @@ def _disconnection_handler(client: Any) -> None:
jade_ble_module.logger.info(f"Connected: {connected}")
except Exception as e:
jade_ble_module.logger.warning(f"BLE connection exception: {e}")
if platform.system() == "Windows" and not attempted_windows_unpair:
attempted_windows_unpair = True
unpaired = await self._try_unpair_windows_device(device_mac=device_mac)
if unpaired:
jade_ble_module.logger.info(
"Removed Windows pairing state for %s; retrying BLE connect",
device_mac,
)

if not attempts_remaining:
jade_ble_module.logger.warning("Exhausted retries - BLE connection failed")
raise JadeError(
Expand Down Expand Up @@ -311,6 +324,26 @@ def _notification_handler(sender: Any, data: Any) -> None:

self.client = connected_client

async def _try_unpair_windows_device(self, device_mac: str) -> bool:
if platform.system() != "Windows":
return False
try:
unpair_client = jade_ble_module.bleak.BleakClient(device_mac)
except Exception as e:
jade_ble_module.logger.warning("Unable to prepare Windows BLE unpair client: %s", e)
return False

try:
result = await self._await_ble_operation(
unpair_client.unpair(),
timeout_seconds=self.gatt_operation_timeout_seconds,
operation_name="unpair",
)
return bool(result)
except Exception as e:
jade_ble_module.logger.warning("Windows BLE unpair failed for %s: %s", device_mac, e)
return False

async def _disconnect_impl(self) -> None:
try:
if self.client is not None and self.client.is_connected:
Expand All @@ -334,6 +367,57 @@ async def _disconnect_impl(self) -> None:
self.write_task.cancel()
self.write_task = None

async def _write_impl(self, bytes_: bytes) -> int: # type: ignore
assert self.client is not None
assert self.write_task is None

towrite = len(bytes_)
written = 0

async def _write() -> None:
if self.client is None:
return
nonlocal written

while written < towrite:
remaining = towrite - written
length = min(remaining, BlockstreamJadeBleImpl.BLE_MAX_WRITE_SIZE)
upper_limit = written + length
await self.client.write_gatt_char(
BlockstreamJadeBleImpl.IO_TX_CHAR_UUID,
bytearray(bytes_[written:upper_limit]),
response=True,
)
written = upper_limit

self.write_task = asyncio.create_task(_write())
try:
await self._await_ble_operation(
self.write_task,
timeout_seconds=self.io_timeout_seconds,
operation_name="write",
)
except asyncio.CancelledError:
jade_ble_module.logger.warning(
"write() task cancelled having written %d of %d bytes", written, towrite
)
finally:
self.write_task = None

return written

async def _read_impl(self, n: int) -> bytes:
assert self.inputstream is not None
return await self._await_ble_operation(
self._read_bytes_from_stream(n=n),
timeout_seconds=self.io_timeout_seconds,
operation_name=f"read({n})",
)

async def _read_bytes_from_stream(self, n: int) -> bytes:
assert self.inputstream is not None
return bytes([b async for b in aioitertools.islice(self.inputstream, n)]) # type: ignore


class JadeBleClient(JadeClient):
"""
Expand Down
Loading