diff --git a/bitcoin_usb/usb_gui.py b/bitcoin_usb/usb_gui.py index c9a8972..1d07c88 100644 --- a/bitcoin_usb/usb_gui.py +++ b/bitcoin_usb/usb_gui.py @@ -122,7 +122,7 @@ def get_devices(self, slow_hwi_listing=False) -> list[dict[str, Any]]: def get_device(self, slow_hwi_listing=False) -> dict[str, Any] | None: "Returns the found devices WITHOUT unlocking them first. Misses the fingerprints" bluetooth_scan_callback: Callable[[], list[dict[str, Any]]] | None = None - if self._is_bluetooth_scan_supported(): + if self.enable_bluetooth: bluetooth_scan_callback = self.get_bluetooth_devices dialog = DeviceDialog( @@ -143,14 +143,17 @@ def get_device(self, slow_hwi_listing=False) -> dict[str, Any] | None: def get_bluetooth_devices(self) -> list[dict[str, Any]]: if not self.enable_bluetooth: raise RuntimeError(self.tr("Bluetooth support is disabled by configuration.")) - if not self._is_bluetooth_scan_supported(): + should_retry_probe = platform.system() == "Darwin" + if not self._is_bluetooth_scan_supported(force_refresh=should_retry_probe): + if should_retry_probe and self._is_bluetooth_scan_supported(force_refresh=True): + return _run_ble_operation(self._discover_bluetooth_devices) raise RuntimeError(self.tr("Bluetooth scanning is not available in this environment.")) return _run_ble_operation(self._discover_bluetooth_devices) - def _is_bluetooth_scan_supported(self) -> bool: + def _is_bluetooth_scan_supported(self, force_refresh: bool = False) -> bool: if not self.enable_bluetooth: return False - if self._bluetooth_scan_supported is None: + if force_refresh or self._bluetooth_scan_supported is None: self._bluetooth_scan_supported = can_scan_bluetooth_devices() return self._bluetooth_scan_supported diff --git a/tests/test_bluetooth_scan_support.py b/tests/test_bluetooth_scan_support.py index 539a3a8..ff0ebec 100644 --- a/tests/test_bluetooth_scan_support.py +++ b/tests/test_bluetooth_scan_support.py @@ -1,4 +1,5 @@ from bitcoin_usb import usb_gui +from bitcoin_usb.usb_gui import USBGui def test_can_scan_bluetooth_devices_uses_ble_operation_wrapper(monkeypatch) -> None: @@ -31,3 +32,89 @@ def raise_runtime_error(_operation): monkeypatch.setattr(usb_gui, "_run_ble_operation", raise_runtime_error) assert usb_gui.can_scan_bluetooth_devices() is False + + +def test_get_bluetooth_devices_retries_support_probe_on_macos(monkeypatch) -> None: + gui = USBGui(network=object(), loop_in_thread=object()) + probe_calls: list[bool] = [] + + def fake_probe() -> bool: + probe_calls.append(True) + return len(probe_calls) > 1 + + monkeypatch.setattr(usb_gui.platform, "system", lambda: "Darwin") + monkeypatch.setattr(usb_gui, "can_scan_bluetooth_devices", fake_probe) + monkeypatch.setattr(usb_gui, "_run_ble_operation", lambda operation: operation()) + monkeypatch.setattr(gui, "_discover_bluetooth_devices", lambda: [{"transport": "bluetooth"}]) + + assert gui.get_bluetooth_devices() == [{"transport": "bluetooth"}] + assert len(probe_calls) == 2 + assert gui._bluetooth_scan_supported is True + + +def test_get_device_exposes_bluetooth_scan_callback_when_enabled(monkeypatch) -> None: + gui = USBGui(network=object(), loop_in_thread=object(), enable_bluetooth=True) + captured: dict[str, object] = {} + callback = lambda: [] + monkeypatch.setattr(gui, "get_bluetooth_devices", callback) + + class _FakeDialog: + def __init__( + self, + parent, + network, + usb_scan_callback, + bluetooth_scan_callback, + install_udev_callback, + autoselect_if_1_device, + ): + _ = parent + _ = network + _ = usb_scan_callback + _ = install_udev_callback + _ = autoselect_if_1_device + captured["bluetooth_scan_callback"] = bluetooth_scan_callback + + def exec(self) -> bool: + return False + + def get_selected_device(self): + raise AssertionError("Not expected when dialog is rejected") + + monkeypatch.setattr(usb_gui, "DeviceDialog", _FakeDialog) + + assert gui.get_device() is None + assert captured["bluetooth_scan_callback"] is callback + + +def test_get_device_hides_bluetooth_scan_callback_when_disabled(monkeypatch) -> None: + gui = USBGui(network=object(), loop_in_thread=object(), enable_bluetooth=False) + captured: dict[str, object] = {} + + class _FakeDialog: + def __init__( + self, + parent, + network, + usb_scan_callback, + bluetooth_scan_callback, + install_udev_callback, + autoselect_if_1_device, + ): + _ = parent + _ = network + _ = usb_scan_callback + _ = install_udev_callback + _ = autoselect_if_1_device + captured["bluetooth_scan_callback"] = bluetooth_scan_callback + + def exec(self) -> bool: + return False + + def get_selected_device(self): + raise AssertionError("Not expected when dialog is rejected") + + monkeypatch.setattr(usb_gui, "DeviceDialog", _FakeDialog) + + assert gui.get_device() is None + assert captured["bluetooth_scan_callback"] is None