diff --git a/bitcoin_safe/gui/icons/hardware_signers/metrovault-icon.svg b/bitcoin_safe/gui/icons/hardware_signers/metrovault-icon.svg new file mode 100644 index 000000000..09b9a510f --- /dev/null +++ b/bitcoin_safe/gui/icons/hardware_signers/metrovault-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py b/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py index bd4c9bd3b..d87d5869f 100644 --- a/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py +++ b/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py @@ -54,6 +54,8 @@ from bitcoin_safe.gui.qt.sidebar.sidebar_tree import SidebarNode, SidebarTree from bitcoin_safe.gui.qt.styled_card_frame import BaseBorderCardFrame from bitcoin_safe.gui.qt.util import ( + Message, + MessageType, color_with_alpha, get_neutral_surface_colors, is_theme_change_event, @@ -66,6 +68,8 @@ logger = logging.getLogger(__name__) +METROVAULT_SIGNER_URL = "https://bitcoin-safe.org/en/library/supported-hardware-signers/metrovault/" + class _ConfigWithWalletDir(Protocol): wallet_dir: str @@ -104,6 +108,7 @@ def __init__(self, icon_name: str, parent: QWidget | None = None) -> None: self.label_description.setTextFormat(Qt.TextFormat.RichText) self._opacity_effect = QGraphicsOpacityEffect(self) self.setGraphicsEffect(self._opacity_effect) + self._available = True self.updateUi() def set_content(self, title: str, description: str) -> None: @@ -116,9 +121,21 @@ def updateUi(self) -> None: self._apply_visual_state() self._refresh_theme_dependent_ui() + def set_available(self, available: bool) -> None: + """Toggle between actionable and greyed-out states without disabling child links.""" + self._available = available + self.set_header_clickable(available) + self._apply_visual_state() + + def _is_header_activatable(self) -> bool: + """Allow unavailable cards to intercept clicks and explain why they are disabled.""" + return not self._available or super()._is_header_activatable() + def _apply_visual_state(self) -> None: self.background_color = get_neutral_surface_colors().panel_background - self._opacity_effect.setOpacity(self._disabled_opacity if not self.isEnabled() else 1.0) + self._opacity_effect.setOpacity( + self._disabled_opacity if not self.isEnabled() or not self._available else 1.0 + ) def changeEvent(self, a0: QEvent | None) -> None: """Refresh card styling when the theme or enabled state changes.""" @@ -481,6 +498,15 @@ def remove_me(self) -> None: def on_click_hot_wallet(self) -> None: """Create a hot wallet.""" + if self.network == bdk.Network.BITCOIN: + Message( + self.tr( + "Hot wallets are disabled on Bitcoin Mainnet.\nYou can switch to Testnet to test Bitcoin Safe without using real Bitcoin." + ), + type=MessageType.Warning, + parent=self, + ) + return self.signal_onclick_hot_wallet.emit() self.signal_remove_me.emit(self) @@ -556,6 +582,9 @@ def create_ui(self) -> None: "hardware_signers/generic-hardware-wallet-icon.svg", self.left_column ) self.card_custom_wallet = WelcomeActionCard("material-symbols--signature.svg", self.left_column) + self.hot_wallet_help_label = IconLabel(parent=self.card_hot_wallet.header_widget) + self.hot_wallet_help_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.card_hot_wallet.header_right_layout.addWidget(self.hot_wallet_help_label) self.connect_devices_help_label = IconLabel(parent=self.card_connect_devices.header_widget) self.connect_devices_help_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self.card_connect_devices.header_right_layout.addWidget(self.connect_devices_help_label) @@ -586,7 +615,7 @@ def updateUi(self) -> None: on_testnet = not on_mainnet self.card_demo_wallet.setVisible(on_testnet) - self.card_hot_wallet.setEnabled(on_testnet) + self.card_hot_wallet.set_available(on_testnet) self.card_demo_wallet.updateUi() self.card_hot_wallet.updateUi() self.card_connect_devices.updateUi() @@ -612,6 +641,12 @@ def updateUi(self) -> None: ) ), ) + self.hot_wallet_help_label.setVisible(on_mainnet) + self.hot_wallet_help_label.setText(self.tr("No signer available?")) + self.hot_wallet_help_label.set_icon_as_help( + self.tr("Learn how to turn an Android phone into a dedicated bitcoin signer."), + METROVAULT_SIGNER_URL, + ) self.card_connect_devices.set_content( title=self.tr("Connect Device(s)"), description=self.tr("Guided setup for your self-custody wallet."), diff --git a/bitcoin_safe/gui/screenshots/metrovault-generate-seed.png b/bitcoin_safe/gui/screenshots/metrovault-generate-seed.png new file mode 100644 index 000000000..60ea060e3 Binary files /dev/null and b/bitcoin_safe/gui/screenshots/metrovault-generate-seed.png differ diff --git a/bitcoin_safe/gui/screenshots/metrovault-register-multisig-decriptor.png b/bitcoin_safe/gui/screenshots/metrovault-register-multisig-decriptor.png new file mode 100644 index 000000000..4d835b1c3 Binary files /dev/null and b/bitcoin_safe/gui/screenshots/metrovault-register-multisig-decriptor.png differ diff --git a/bitcoin_safe/gui/screenshots/metrovault-wallet-export.png b/bitcoin_safe/gui/screenshots/metrovault-wallet-export.png new file mode 100644 index 000000000..7213a4e8f Binary files /dev/null and b/bitcoin_safe/gui/screenshots/metrovault-wallet-export.png differ diff --git a/bitcoin_safe/hardware_signers.py b/bitcoin_safe/hardware_signers.py index ac2920077..4127643eb 100644 --- a/bitcoin_safe/hardware_signers.py +++ b/bitcoin_safe/hardware_signers.py @@ -40,6 +40,7 @@ logger = logging.getLogger(__name__) SUPPORTED_HARDWARE_SIGNERS_URL = "https://bitcoin-safe.org/en/knowledge/supported-hardware-signers/" +METROVAULT_REPOSITORY_URL = "https://github.com/gorunjinian/MetroVault" def _signer_info_url(path: str | None = None) -> str: @@ -252,6 +253,22 @@ class HardwareSigners: icon_filename="jade-plus-icon.svg", screenshot_name="jade", ) + metrovault = HardwareSigner( + id="metrovault", + brand_name="MetroVault", + display_name="MetroVault", + qr_types=[ + QrExportTypes.bbqr, + QrExportTypes.ur, + DescriptorQrExportTypes.default, + DescriptorQrExportTypes.text, + SignMessageRequestQrExportTypes.text, + ], + descriptor_export_types=[DescriptorExportTypes.default, DescriptorExportTypes.text], + usb=FeatureLevel.not_capable, + info_url=METROVAULT_REPOSITORY_URL, + icon_filename="metrovault-icon.svg", + ) passport = HardwareSigner( id="passport", brand_name="Foundation", diff --git a/release-notes/2.1.0.md b/release-notes/2.1.0.md index 3f4d6d558..55a4d9f78 100644 --- a/release-notes/2.1.0.md +++ b/release-notes/2.1.0.md @@ -1,6 +1,8 @@ Highlights +- Light/Dark theme switching +- Added MetroVault support - Upgrade Bitcoin Dev Kit (BDK) to 3.0 -- Reenable custom account xpub fetching for USB signers - Show the connected Bitcoin nodes for compact block filter fetching on the map +- Re-store custom account xpub fetching for USB signers - Bug fixes diff --git a/tests/gui/qt/test_card_base.py b/tests/gui/qt/test_card_base.py index a11af888f..f66a1939e 100644 --- a/tests/gui/qt/test_card_base.py +++ b/tests/gui/qt/test_card_base.py @@ -29,6 +29,8 @@ from __future__ import annotations +from unittest.mock import Mock + import bdkpython as bdk from PyQt6.QtCore import QEvent, Qt from PyQt6.QtGui import QColor, QPalette @@ -37,6 +39,7 @@ from bitcoin_safe.gui.qt.card_base import CardBase, CardExpansionMode, CardList from bitcoin_safe.gui.qt.new_wallet_welcome_screen import ( + METROVAULT_SIGNER_URL, NetworkChoiceCard, NetworkChoiceWelcomeScreen, NewWalletWelcomeScreen, @@ -425,3 +428,31 @@ def apply_palette(window: str, text: str) -> str: assert to_color_name(screen.card_secure_wallet.cta_panel.background_color) == to_color_name( get_neutral_surface_colors().panel_background ) + + +def test_mainnet_hot_wallet_card_keeps_help_link_clickable(qtbot: QtBot, monkeypatch) -> None: + screen = NewWalletWelcomeScreen(network=bdk.Network.BITCOIN, signals=Signals()) + qtbot.addWidget(screen) + screen.show() + qtbot.waitExposed(screen) + + hot_wallet_clicked = Mock() + message_mock = Mock() + open_mock = Mock() + screen.signal_onclick_hot_wallet.connect(hot_wallet_clicked) + monkeypatch.setattr("bitcoin_safe.gui.qt.new_wallet_welcome_screen.Message", message_mock) + monkeypatch.setattr("bitcoin_safe.gui.qt.icon_label.webopen", open_mock) + + assert screen.hot_wallet_help_label.isVisible() + assert screen.hot_wallet_help_label.textLabel.text() == "No signer available?" + assert screen.card_hot_wallet.graphicsEffect() is not None + assert screen.card_hot_wallet.header_title.cursor().shape() == Qt.CursorShape.ArrowCursor + + qtbot.mouseClick(screen.card_hot_wallet.header_title, Qt.MouseButton.LeftButton) + hot_wallet_clicked.assert_not_called() + message_mock.assert_called_once() + assert "Hot wallets are disabled on Bitcoin Mainnet." in message_mock.call_args.args[0] + assert message_mock.call_args.kwargs["type"].name == "Warning" + + qtbot.mouseClick(screen.hot_wallet_help_label.textLabel, Qt.MouseButton.LeftButton) + open_mock.assert_called_once_with(METROVAULT_SIGNER_URL) diff --git a/tests/non_gui/test_hardware_signers.py b/tests/non_gui/test_hardware_signers.py index 71cb0302e..d8faf2851 100644 --- a/tests/non_gui/test_hardware_signers.py +++ b/tests/non_gui/test_hardware_signers.py @@ -55,11 +55,25 @@ def test_q_supports_usb() -> None: assert HardwareSigners.q.usb == FeatureLevel.supported +def test_metrovault_brand_exposes_single_matching_model() -> None: + """Test MetroVault appears as a single-model mobile signer brand.""" + assert [signer.id for signer in HardwareSigners.models_for_brand("MetroVault")] == [ + HardwareSigners.metrovault.id + ] + assert HardwareSigners.metrovault.display_name == "MetroVault" + assert HardwareSigners.metrovault.icon_name == "metrovault-icon.svg" + + def test_infer_from_text_matches_display_name() -> None: """Test free-form text inference resolves a known display name.""" assert HardwareSigners.infer_from_text("Krux App backup") == HardwareSigners.krux_diy +def test_infer_from_text_matches_metrovault() -> None: + """Test free-form text inference resolves MetroVault by name.""" + assert HardwareSigners.infer_from_text("MetroVault signer backup") == HardwareSigners.metrovault + + def test_signer_ids_are_unique() -> None: """Test each configured signer has a unique persisted identifier.""" signer_ids = [signer.id for signer in HardwareSigners.as_list()] diff --git a/tools/build_linux/flathub_flatpak/org.bitcoin_safe.BitcoinSafe.metainfo.xml b/tools/build_linux/flathub_flatpak/org.bitcoin_safe.BitcoinSafe.metainfo.xml index fae0c3501..9e6fed3c3 100644 --- a/tools/build_linux/flathub_flatpak/org.bitcoin_safe.BitcoinSafe.metainfo.xml +++ b/tools/build_linux/flathub_flatpak/org.bitcoin_safe.BitcoinSafe.metainfo.xml @@ -57,9 +57,11 @@

Highlights