From f766f69fe7b7832cb71ec53108050991741f24c1 Mon Sep 17 00:00:00 2001 From: andreasgriffin Date: Wed, 24 Jul 2024 19:49:15 +0200 Subject: [PATCH] GUI improvements and bugfixes --- .pre-commit-config.yaml | 10 + .update_version.py | 50 ++++ bitcoin_safe/__init__.py | 3 +- bitcoin_safe/gui/qt/address_dialog.py | 141 +++++---- bitcoin_safe/gui/qt/address_edit.py | 167 +++++++++++ bitcoin_safe/gui/qt/buttonedit.py | 2 +- bitcoin_safe/gui/qt/descriptor_edit.py | 4 +- bitcoin_safe/gui/qt/export_data.py | 6 +- bitcoin_safe/gui/qt/hist_list.py | 4 +- bitcoin_safe/gui/qt/main.py | 4 +- bitcoin_safe/gui/qt/my_treeview.py | 1 + bitcoin_safe/gui/qt/qt_wallet.py | 92 ++++-- bitcoin_safe/gui/qt/recipients.py | 382 ++++++++++++++---------- bitcoin_safe/gui/qt/tutorial.py | 8 +- bitcoin_safe/gui/qt/tx_signing_steps.py | 4 +- bitcoin_safe/gui/qt/ui_tx.py | 33 +- bitcoin_safe/logging_handlers.py | 7 +- bitcoin_safe/signer.py | 2 +- poetry.lock | 8 +- pyproject.toml | 4 +- tests/gui/qt/test_gui_setup_wallet.py | 6 +- tests/test_signers.py | 6 +- tools/release.py | 134 +++++++-- 23 files changed, 764 insertions(+), 314 deletions(-) create mode 100644 .update_version.py create mode 100644 bitcoin_safe/gui/qt/address_edit.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7333bf9..b7cf864 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,6 +49,16 @@ repos: - types-toml + - repo: local + hooks: + - id: update-poetry-version + name: Update Poetry Version + entry: python .update_version.py + language: python + always_run: true + files: pyproject.toml + additional_dependencies: + - toml # - repo: https://github.com/PyCQA/bandit # rev: 1.7.6 # Use the latest version diff --git a/.update_version.py b/.update_version.py new file mode 100644 index 0000000..45fdd70 --- /dev/null +++ b/.update_version.py @@ -0,0 +1,50 @@ +# +# Bitcoin Safe +# Copyright (C) 2024 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import toml + +from bitcoin_safe import __version__ + + +def update_poetry_version(file_path, new_version): + # Read the pyproject.toml file + with open(file_path, "r") as file: + data = toml.load(file) + + # Update the version under tool.poetry + if "tool" in data and "poetry" in data["tool"] and "version" in data["tool"]["poetry"]: + data["tool"]["poetry"]["version"] = new_version + # Write the updated data back to pyproject.toml + with open(file_path, "w") as file: + toml.dump(data, file) + print(f"Version updated to {new_version} in pyproject.toml") + else: + print("Could not find the 'tool.poetry.version' key in the pyproject.toml") + + +update_poetry_version("pyproject.toml", __version__) diff --git a/bitcoin_safe/__init__.py b/bitcoin_safe/__init__.py index 9cc9c86..a3fcdda 100644 --- a/bitcoin_safe/__init__.py +++ b/bitcoin_safe/__init__.py @@ -1 +1,2 @@ -__version__ = "0.7.1a0" +# this is the source of the version information +__version__ = "0.7.2a0" diff --git a/bitcoin_safe/gui/qt/address_dialog.py b/bitcoin_safe/gui/qt/address_dialog.py index 9aef1f6..340aa6f 100644 --- a/bitcoin_safe/gui/qt/address_dialog.py +++ b/bitcoin_safe/gui/qt/address_dialog.py @@ -28,11 +28,13 @@ import logging +import typing from bitcoin_qr_tools.qr_widgets import QRCodeWidgetSVG from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.buttonedit import ButtonEdit +from bitcoin_safe.gui.qt.recipients import RecipientTabWidget from bitcoin_safe.mempool import MempoolData from bitcoin_safe.util import serialized_to_hex @@ -40,11 +42,11 @@ import bdkpython as bdk from PyQt6.QtCore import Qt +from PyQt6.QtGui import QKeyEvent from PyQt6.QtWidgets import ( + QFormLayout, QHBoxLayout, - QLabel, QSizePolicy, - QTabWidget, QTextEdit, QVBoxLayout, QWidget, @@ -52,10 +54,53 @@ from ...signals import Signals from ...wallet import Wallet -from .hist_list import HistList, HistListWithToolbar +from .hist_list import HistList from .util import Buttons, CloseButton +class AddressDetailsAdvanced(QWidget): + def __init__( + self, bdk_address: bdk.Address, address_path_str: str, parent: typing.Optional["QWidget"] + ) -> None: + super().__init__(parent) + + form_layout = QFormLayout(self) + form_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + + try: + script_pubkey = serialized_to_hex(bdk_address.script_pubkey().to_bytes()) + except BaseException: + script_pubkey = None + if script_pubkey: + pubkey_e = ButtonEdit(script_pubkey) + pubkey_e.button_container.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + pubkey_e.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + + pubkey_e.add_copy_button() + pubkey_e.setReadOnly(True) + + form_layout.addRow(self.tr("Script Pubkey"), pubkey_e) + + if address_path_str: + der_path_e = ButtonEdit(address_path_str, input_field=QTextEdit()) + der_path_e.add_copy_button() + der_path_e.setFixedHeight(50) + der_path_e.setReadOnly(True) + + form_layout.addRow(self.tr("Address descriptor"), der_path_e) + + +class QRAddress(QRCodeWidgetSVG): + def __init__( + self, + ) -> None: + super().__init__(clickable=False) + self.setMaximumSize(150, 150) + + def set_address(self, bdk_address: bdk.Address): + self.set_data_list([bdk_address.to_qr_uri()]) + + class AddressDialog(QWidget): def __init__( self, @@ -83,63 +128,37 @@ def __init__( self.setLayout(vbox) upper_widget = QWidget() - upper_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - upper_widget_layout = QHBoxLayout(upper_widget) - upper_widget_layout.setContentsMargins(0, 0, 0, 0) + # upper_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + upper_widget.setLayout(QHBoxLayout()) + upper_widget.layout().setContentsMargins(0, 0, 0, 0) vbox.addWidget(upper_widget) - self.tabs = QTabWidget() - upper_widget_layout.addWidget(self.tabs) - - self.tab_details = QWidget() - tab1_layout = QVBoxLayout(self.tab_details) - tab1_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.tabs.addTab(self.tab_details, "") - self.tab_advanced = QWidget() - tab2_layout = QVBoxLayout(self.tab_advanced) - tab2_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.tabs.addTab(self.tab_advanced, "") - - address_info_min = self.wallet.get_address_info_min(address) - if address_info_min: - - address_title = ( - self.tr("Receiving address of wallet '{wallet_id}' (with index {index})") - if address_info_min.keychain == bdk.KeychainKind.EXTERNAL - else self.tr("Change address of wallet '{wallet_id}' (with index {index})") - ).format(wallet_id=wallet.id, index=address_info_min.index) - tab1_layout.addWidget(QLabel(self.tr(address_title) + ":")) - self.addr_e = ButtonEdit(self.address) - self.addr_e.setReadOnly(True) - self.addr_e.add_copy_button() - # self.addr_e.setStyleSheet(f"background-color: {ColorScheme.GREEN.as_color(True).name()};") - tab1_layout.addWidget(self.addr_e) + self.recipient_tabs = RecipientTabWidget( + network=wallet.network, + allow_edit=False, + parent=self, + signals=self.signals, + tab_string=self.tr('Address of wallet "{id}"'), + dismiss_label_on_focus_loss=True, + ) + self.recipient_tabs.address = self.address + label = wallet.labels.get_label(self.address) + self.recipient_tabs.label = label if label else "" + self.recipient_tabs.amount = wallet.get_addr_balance(self.address).total - try: - script_pubkey = serialized_to_hex(self.bdk_address.script_pubkey().to_bytes()) - except BaseException: - script_pubkey = None - if script_pubkey: - tab2_layout.addWidget(QLabel(self.tr("Script Pubkey") + ":")) - pubkey_e = ButtonEdit(script_pubkey) - pubkey_e.add_copy_button() - pubkey_e.setReadOnly(True) - tab2_layout.addWidget(pubkey_e) + upper_widget.layout().addWidget(self.recipient_tabs) - address_path_str = self.wallet.get_address_path_str(address) - if address_path_str: - tab2_layout.addWidget(QLabel(self.tr("Address descriptor") + ":")) - der_path_e = ButtonEdit(address_path_str, input_field=QTextEdit()) - der_path_e.add_copy_button() - der_path_e.setFixedHeight(50) - der_path_e.setReadOnly(True) - tab2_layout.addWidget(der_path_e) + self.tab_advanced = AddressDetailsAdvanced( + bdk_address=self.bdk_address, + address_path_str=self.wallet.get_address_path_str(address), + parent=self, + ) + self.recipient_tabs.addTab(self.tab_advanced, "") - self.qr_code = QRCodeWidgetSVG() - self.qr_code.set_data_list([self.bdk_address.to_qr_uri()]) - self.qr_code.setMaximumWidth(150) - upper_widget_layout.addWidget(self.qr_code) + self.qr_code = QRAddress() + self.qr_code.set_address(self.bdk_address) + upper_widget.layout().addWidget(self.qr_code) self.hist_list = HistList( self.fx, @@ -154,12 +173,18 @@ def __init__( address_domain=[self.address], column_widths={HistList.Columns.TXID: 100}, ) - toolbar = HistListWithToolbar(self.hist_list, self.config, parent=self) - vbox.addWidget(toolbar) + vbox.addWidget(self.hist_list) vbox.addLayout(Buttons(CloseButton(self))) self.setupUi() + # Override keyPressEvent method + def keyPressEvent(self, event: QKeyEvent) -> None: + # Check if the pressed key is 'Esc' + if event.key() == Qt.Key.Key_Escape: + # Close the widget + self.close() + def setupUi(self) -> None: - self.tabs.setTabText(self.tabs.indexOf(self.tab_details), self.tr("Details")) - self.tabs.setTabText(self.tabs.indexOf(self.tab_advanced), self.tr("Advanced")) + self.recipient_tabs.updateUi() + self.recipient_tabs.setTabText(self.recipient_tabs.indexOf(self.tab_advanced), self.tr("Advanced")) diff --git a/bitcoin_safe/gui/qt/address_edit.py b/bitcoin_safe/gui/qt/address_edit.py new file mode 100644 index 0000000..04e5ced --- /dev/null +++ b/bitcoin_safe/gui/qt/address_edit.py @@ -0,0 +1,167 @@ +# +# Bitcoin Safe +# Copyright (C) 2024 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +import logging + +from bitcoin_safe.gui.qt.buttonedit import ButtonEdit + +logger = logging.getLogger(__name__) + +from typing import Optional + +import bdkpython as bdk +from bitcoin_qr_tools.data import Data, DataType +from PyQt6 import QtCore, QtGui +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QMessageBox, QWidget + +from ...i18n import translate +from ...signals import Signals +from ...wallet import Wallet, get_wallets +from .dialogs import question_dialog +from .util import ColorScheme + + +class AddressEdit(ButtonEdit): + signal_text_change = pyqtSignal(str) + signal_bip21_input = pyqtSignal(Data) + + def __init__( + self, + network: bdk.Network, + text="", + allow_edit: bool = True, + button_vertical_align: Optional[QtCore.Qt] = None, + parent=None, + signals: Signals = None, + ) -> None: + self.signals = signals + self.network = network + self.allow_edit = allow_edit + super().__init__( + text=text, + button_vertical_align=button_vertical_align, + parent=parent, + signal_update=signals.language_switch if signals else None, + ) + + self.setPlaceholderText(self.tr("Enter address here")) + + def on_handle_input(data: Data, parent: QWidget) -> None: + if data.data_type == DataType.Bip21: + if data.data.get("address"): + self.setText(data.data.get("address")) + self.signal_bip21_input.emit(data) + + if allow_edit: + self.add_qr_input_from_camera_button( + network=network, + custom_handle_input=on_handle_input, + ) + else: + self.add_copy_button() + self.setReadOnly(True) + + def is_valid() -> bool: + if not self.text(): + # if it is empty, show no error + return True + try: + bdk_address = bdk.Address(self.address, network=network) + return bool(bdk_address) + except: + return False + + self.set_validator(is_valid) + self.input_field.textChanged.connect(self.on_text_changed) + + @property + def address(self) -> str: + return self.text().strip() + + @address.setter + def address(self, value: str) -> None: + self.setText(value) + + def get_wallet_of_address(self) -> Optional[Wallet]: + if not self.signals: + return None + for wallet in get_wallets(self.signals): + if wallet.is_my_address(self.address): + return wallet + return None + + def updateUi(self): + super().updateUi() + + wallet = self.get_wallet_of_address() + self.format_address_field(wallet=wallet) + + def on_text_changed(self, *args): + wallet = self.get_wallet_of_address() + + self.format_address_field(wallet=wallet) + + if wallet and wallet.address_is_used(self.address) and self.allow_edit: + self.ask_to_replace_address(wallet, self.address) + + self.signal_text_change.emit(self.address) + + def format_address_field(self, wallet: Optional[Wallet]) -> None: + palette = QtGui.QPalette() + background_color = None + + if wallet: + if wallet.is_change(self.address): + background_color = ColorScheme.YELLOW.as_color(background=True) + palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) + else: + background_color = ColorScheme.GREEN.as_color(background=True) + palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) + + else: + palette = self.input_field.style().standardPalette() + + self.input_field.setPalette(palette) + self.input_field.update() + self.update() + logger.debug( + f"{self.__class__.__name__} format_address_field for self.address {self.address}, background_color = {background_color.name() if background_color else None}" + ) + + def ask_to_replace_address(self, wallet: Wallet, address: str) -> None: + if question_dialog( + text=translate( + "recipients", + f"Address {address} was used already. Would you like to get a fresh receiving address?", + ), + title=translate("recipients", "Address Already Used"), + buttons=QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes, + ): + self.address = wallet.get_address().address.as_string() diff --git a/bitcoin_safe/gui/qt/buttonedit.py b/bitcoin_safe/gui/qt/buttonedit.py index c8ffb55..bf72524 100644 --- a/bitcoin_safe/gui/qt/buttonedit.py +++ b/bitcoin_safe/gui/qt/buttonedit.py @@ -155,7 +155,7 @@ def __init__( self.input_field: Union[QTextEdit, QLineEdit] = input_field if input_field else QLineEdit(self) if text: self.input_field.setText(text) - self.button_container = (ButtonsField)( + self.button_container = ButtonsField( vertical_align=button_vertical_align if button_vertical_align else ( diff --git a/bitcoin_safe/gui/qt/descriptor_edit.py b/bitcoin_safe/gui/qt/descriptor_edit.py index ad5fc00..74cddf4 100644 --- a/bitcoin_safe/gui/qt/descriptor_edit.py +++ b/bitcoin_safe/gui/qt/descriptor_edit.py @@ -30,7 +30,7 @@ import logging from typing import Callable, Optional -from bitcoin_qr_tools.data import Data, DataType +from bitcoin_qr_tools.data import Data from bitcoin_safe.gui.qt.buttonedit import ButtonEdit from bitcoin_safe.gui.qt.custom_edits import MyTextEdit @@ -59,7 +59,7 @@ def __init__(self, descriptor: MultipathDescriptor, signals_min: SignalsMin, par self.setModal(True) self.descriptor = descriptor - self.data = Data(descriptor, DataType.MultiPathDescriptor) + self.data = Data.from_multipath_descriptor(descriptor) export_widget = ExportDataSimple( data=self.data, diff --git a/bitcoin_safe/gui/qt/export_data.py b/bitcoin_safe/gui/qt/export_data.py index 285e529..2d7d6f1 100644 --- a/bitcoin_safe/gui/qt/export_data.py +++ b/bitcoin_safe/gui/qt/export_data.py @@ -188,7 +188,7 @@ def __init__( self.group_qr_buttons.layout().addWidget(self.button_enlarge_qr) self.button_save_qr = QPushButton() - self.button_save_qr.setIcon(read_QIcon("download.png")) + # self.button_save_qr.setIcon(read_QIcon("download.png")) self.button_save_qr.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)) self.button_save_qr.clicked.connect(self.export_qrcode) self.group_qr_buttons.layout().addWidget(self.button_save_qr) @@ -226,7 +226,9 @@ def fill_combo_qr_type(self, qr_types: List[str]): self.combo_qr_type.clear() for qr_type in qr_types: self.combo_qr_type.addItem( - self.tr("Show {} QR code").format(self.qr_types_descriptions[qr_type]), userData=qr_type + read_QIcon("qr-code.svg"), + self.tr("{} QR code").format(self.qr_types_descriptions[qr_type]), + userData=qr_type, ) self.combo_qr_type.blockSignals(False) diff --git a/bitcoin_safe/gui/qt/hist_list.py b/bitcoin_safe/gui/qt/hist_list.py index 53340e2..c084715 100644 --- a/bitcoin_safe/gui/qt/hist_list.py +++ b/bitcoin_safe/gui/qt/hist_list.py @@ -72,7 +72,7 @@ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple import bdkpython as bdk -from bitcoin_qr_tools.data import Data, DataType +from bitcoin_qr_tools.data import Data from PyQt6.QtCore import ( QModelIndex, QPersistentModelIndex, @@ -247,7 +247,7 @@ def get_file_data(self, txid: str) -> Optional[Data]: for wallet in get_wallets(self.signals): txdetails = wallet.get_tx(txid) if txdetails: - return Data(txdetails.transaction, DataType.Tx) + return Data.from_tx(txdetails.transaction) return None def drag_keys_to_file_paths( diff --git a/bitcoin_safe/gui/qt/main.py b/bitcoin_safe/gui/qt/main.py index 933ec75..d7cb7a3 100644 --- a/bitcoin_safe/gui/qt/main.py +++ b/bitcoin_safe/gui/qt/main.py @@ -672,7 +672,7 @@ def get_outpoints() -> List[OutPoint]: fee_info=FeeInfo(fee, tx.vsize(), is_estimated=False) if fee is not None else None, confirmation_time=confirmation_time, blockchain=self.get_blockchain_of_any_wallet(), - data=Data(tx, data_type=DataType.Tx), + data=Data.from_tx(tx), ) add_tab_to_tabs( @@ -748,7 +748,7 @@ def get_outpoints() -> List[OutPoint]: mempool_data=self.mempool_data, fee_info=fee_info, blockchain=self.get_blockchain_of_any_wallet(), - data=Data(psbt, data_type=DataType.PSBT), + data=Data.from_psbt(psbt), ) psbt.extract_tx().txid() diff --git a/bitcoin_safe/gui/qt/my_treeview.py b/bitcoin_safe/gui/qt/my_treeview.py index 6e548c2..8657f89 100644 --- a/bitcoin_safe/gui/qt/my_treeview.py +++ b/bitcoin_safe/gui/qt/my_treeview.py @@ -864,6 +864,7 @@ def dropEvent(self, event: QDropEvent) -> None: def update(self) -> None: super().update() + logger.debug(f"{self.__class__.__name__} done updating") self.signal_update.emit() diff --git a/bitcoin_safe/gui/qt/qt_wallet.py b/bitcoin_safe/gui/qt/qt_wallet.py index 52753f7..08f7d98 100644 --- a/bitcoin_safe/gui/qt/qt_wallet.py +++ b/bitcoin_safe/gui/qt/qt_wallet.py @@ -36,6 +36,7 @@ from typing import Any, Callable, Dict, List, Optional, Set, Tuple import bdkpython as bdk +from bitcoin_qr_tools.data import Data from PyQt6.QtCore import QObject, QTimer, pyqtSignal from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import ( @@ -291,6 +292,7 @@ def updateUi(self) -> None: def stop_sync_timer(self) -> None: self.timer_sync_retry.stop() + self.timer_sync_regularly.stop() def close(self) -> None: self.disconnect_signals() @@ -551,40 +553,70 @@ def get_delta_txs(self, access_marker="notifications") -> DeltaCacheListTransact delta_txs = self.wallet.bdkwallet.list_delta_transactions(access_marker=access_marker) return delta_txs - def notify_on_new_txs(self, delta_txs: DeltaCacheListTransactions) -> None: - def format_txs(txs: List[bdk.TransactionDetails]) -> str: - return " \n".join( - [ - f"{Satoshis( tx.received- tx.sent, self.config.network).str_as_change(unit=True)} in {short_tx_id( tx.txid)}" - for tx in txs - ] - ) + def format_txs(self, txs: List[bdk.TransactionDetails]) -> str: + return "\n".join( + [ + self.tr(" {amount} in {shortid}").format( + amount=Satoshis(tx.received - tx.sent, self.config.network).str_as_change(unit=True), + shortid=short_tx_id(tx.txid), + ) + for tx in txs + ] + ) + + def hanlde_removed_txs(self, removed_txs: List[bdk.TransactionDetails]) -> None: + if not removed_txs: + return # if transactions were removed (reorg or other), then recalculate everything - if delta_txs.removed: + message_content = self.tr( + "The transactions \n{txs}\n in wallet '{wallet}' were removed from the history!!!" + ).format(txs=self.format_txs(removed_txs), wallet=self.wallet.id) + Message( + message_content, + no_show=True, + ).emit_with(self.signals.notification) + if question_dialog( + message_content + "\n" + self.tr("Do you want to save a copy of these transactions?") + ): + folder_path = QFileDialog.getExistingDirectory( + self.parent(), "Select Folder to save the removed transactions" + ) + + if folder_path: + for tx in removed_txs: + data = Data.from_tx(tx.transaction) + filename = Path(folder_path) / f"{short_tx_id( tx.txid)}.tx" + + # create a file descriptor + fd = os.open(filename, os.O_CREAT | os.O_WRONLY) + data.write_to_filedescriptor(fd) + logger.info(f"Exported {tx.txid} to {filename}") + + def handle_appended_txs(self, appended_txs: List[bdk.TransactionDetails]) -> None: + if not appended_txs: + return + + if len(appended_txs) == 1: Message( - self.tr( - "The transactions {txs} in wallet '{wallet}' were removed from the history!!!" - ).format(txs=format_txs(delta_txs.removed), wallet=self.wallet.id), + self.tr("New transaction in wallet '{wallet}':\n{txs}").format( + txs=self.format_txs(appended_txs), wallet=self.wallet.id + ), no_show=True, ).emit_with(self.signals.notification) - elif delta_txs.appended: - if len(delta_txs.appended) == 1: - Message( - self.tr("New transaction in wallet '{wallet}':\n{txs}").format( - txs=format_txs(delta_txs.appended), wallet=self.wallet.id - ), - no_show=True, - ).emit_with(self.signals.notification) - else: - Message( - self.tr("{number} new transactions in wallet '{wallet}':\n{txs}").format( - number=len(delta_txs.appended), - txs=format_txs(delta_txs.appended), - wallet=self.wallet.id, - ), - no_show=True, - ).emit_with(self.signals.notification) + else: + Message( + self.tr("{number} new transactions in wallet '{wallet}':\n{txs}").format( + number=len(appended_txs), + txs=self.format_txs(appended_txs), + wallet=self.wallet.id, + ), + no_show=True, + ).emit_with(self.signals.notification) + + def handle_delta_txs(self, delta_txs: DeltaCacheListTransactions) -> None: + self.hanlde_removed_txs(delta_txs.removed) + self.handle_appended_txs(delta_txs.appended) def refresh_caches_and_ui_lists(self, threaded=ENABLE_THREADING, force_ui_refresh=True) -> None: # before the wallet UI updates, we have to refresh the wallet caches to make the UI update faster @@ -599,7 +631,7 @@ def on_done(result) -> None: change_dict = delta_txs.was_changed() logger.debug(f"change_dict={change_dict}") if force_ui_refresh or change_dict: - self.notify_on_new_txs(delta_txs) + self.handle_delta_txs(delta_txs) # now do the UI logger.debug("start refresh ui") diff --git a/bitcoin_safe/gui/qt/recipients.py b/bitcoin_safe/gui/qt/recipients.py index 201ad0c..868a910 100644 --- a/bitcoin_safe/gui/qt/recipients.py +++ b/bitcoin_safe/gui/qt/recipients.py @@ -29,22 +29,25 @@ import logging -from bitcoin_safe.gui.qt.buttonedit import ButtonEdit +from bitcoin_safe.gui.qt.address_edit import AddressEdit from ...pythonbdk_types import Recipient from .invisible_scroll_area import InvisibleScrollArea logger = logging.getLogger(__name__) -from typing import List, Optional +from typing import List import bdkpython as bdk from bitcoin_qr_tools.data import Data, DataType -from PyQt6 import QtCore, QtGui, QtWidgets -from PyQt6.QtCore import QSize +from PyQt6 import QtCore, QtWidgets +from PyQt6.QtCore import QSize, Qt, pyqtSignal +from PyQt6.QtGui import QKeyEvent from PyQt6.QtWidgets import ( + QFormLayout, + QHBoxLayout, QLabel, - QMessageBox, + QLineEdit, QPushButton, QSizePolicy, QStyle, @@ -54,24 +57,9 @@ QWidget, ) -from ...i18n import translate -from ...signals import Signals +from ...signals import Signals, UpdateFilter from ...util import unit_str -from ...wallet import Wallet, get_wallets -from .dialogs import question_dialog from .spinbox import BTCSpinBox -from .util import ColorScheme - - -def dialog_replace_with_new_receiving_address(address: str) -> bool: - return question_dialog( - text=translate( - "recipients", - f"Address {address} was used already. Would you like to get a fresh receiving address?", - ), - title=translate("recipients", "Address Already Used"), - buttons=QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes, - ) class CloseButton(QPushButton): @@ -89,67 +77,73 @@ def paintEvent(self, event) -> None: painter.drawControl(QStyle.ControlElement.CE_PushButton, option) -class RecipientGroupBox(QTabWidget): - signal_close = QtCore.pyqtSignal(QTabWidget) - - def __init__(self, signals: Signals, network: bdk.Network, allow_edit=True, title="") -> None: - super().__init__() - self.setTabsClosable(allow_edit) - self.title = title - self.tab = QWidget() - self.addTab(self.tab, title) +class LabelLineEdit(QLineEdit): + signal_enterPressed = pyqtSignal() # Signal for Enter key + signal_textEditedAndFocusLost = pyqtSignal() # Signal for text edited and focus lost + def __init__(self, parent=None): + super().__init__(parent) + self.originalText = "" + self.textChangedSinceFocus = False + self.installEventFilter(self) # Install an event filter + self.textChanged.connect(self.onTextChanged) # Connect the textChanged signal + + def onTextChanged(self): + self.textChangedSinceFocus = True # Set flag when text changes + + def eventFilter(self, obj, event): + if obj == self: + if event.type() == QKeyEvent.Type.FocusIn: + self.originalText = self.text() # Store text when focused + self.textChangedSinceFocus = False # Reset change flag + elif event.type() == QKeyEvent.Type.FocusOut: + if self.textChangedSinceFocus: + self.signal_textEditedAndFocusLost.emit() # Emit signal if text was edited + self.textChangedSinceFocus = False # Reset change flag + return super().eventFilter(obj, event) + + def keyPressEvent(self, event: QKeyEvent): + if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + self.signal_enterPressed.emit() # Emit Enter pressed signal + elif event.key() == Qt.Key.Key_Escape: + self.setText(self.originalText) # Reset text on ESC + else: + super().keyPressEvent(event) + + +class RecipientWidget(QWidget): + def __init__( + self, + signals: Signals, + network: bdk.Network, + allow_edit=True, + allow_label_edit=True, + parent=None, + dismiss_label_on_focus_loss=True, + ) -> None: + super().__init__(parent=parent) self.signals = signals self.allow_edit = allow_edit + self.allow_label_edit = allow_label_edit - self.tabCloseRequested.connect(lambda: self.signal_close.emit(self)) - - form_layout = QtWidgets.QFormLayout() - self.tab.setLayout(form_layout) + self.form_layout = QFormLayout() + self.setLayout(self.form_layout) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - form_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - - def on_handle_input(data: Data, parent: QWidget) -> None: - if data.data_type == DataType.Bip21: - if data.data.get("address"): - self.address_line_edit.setText(data.data.get("address")) - if data.data.get("amount"): - self.amount_spin_box.setValue(data.data.get("amount")) - if data.data.get("label"): - self.label_line_edit.setText(data.data.get("label")) - - self.address_line_edit = ButtonEdit(signal_update=self.signals.language_switch) - if allow_edit: - self.address_line_edit.add_qr_input_from_camera_button( - network=network, - custom_handle_input=on_handle_input, - ) - else: - self.address_line_edit.add_copy_button() - self.address_line_edit.setReadOnly(True) - - def is_valid() -> bool: - if not self.address_line_edit.text(): - # if it is empty, show no error - return True - try: - bdk_address = bdk.Address(self.address_line_edit.text().strip(), network=network) - if self.signals.get_network.emit() == bdk.Network.SIGNET: - # bdk treats signet AND testnet as testnet - assert bdk_address.network() in [bdk.Network.SIGNET, bdk.Network.TESTNET] - else: - assert bdk_address.network() == self.signals.get_network.emit() - return True - except: - return False - - self.address_line_edit.set_validator(is_valid) - self.label_line_edit = QtWidgets.QLineEdit() - - self.amount_layout = QtWidgets.QHBoxLayout() + self.form_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + # works only for automatically created QLabels + # self.form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) + + self.address_edit = AddressEdit( + network=network, allow_edit=allow_edit, parent=self, signals=self.signals + ) + # ensure that the address_edit is the minimum vertical size + self.address_edit.button_container.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + self.label_line_edit = LabelLineEdit() + + self.amount_layout = QHBoxLayout() self.amount_spin_box = BTCSpinBox(self.signals.get_network()) - self.label_unit = QtWidgets.QLabel(unit_str(self.signals.get_network())) - self.send_max_button = QtWidgets.QPushButton() + self.label_unit = QLabel(unit_str(self.signals.get_network())) + self.send_max_button = QPushButton() self.send_max_button.setCheckable(True) self.send_max_button.setMaximumWidth(80) self.send_max_button.clicked.connect(self.on_send_max_button_click) @@ -159,24 +153,53 @@ def is_valid() -> bool: self.amount_layout.addWidget(self.send_max_button) self.address_label = QLabel() + self.address_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) self.label_txlabel = QLabel() + self.label_txlabel.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) self.amount_label = QLabel() + self.amount_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) - form_layout.addRow(self.address_label, self.address_line_edit) - form_layout.addRow(self.label_txlabel, self.label_line_edit) - form_layout.addRow(self.amount_label, self.amount_layout) + self.form_layout.addRow(self.address_label, self.address_edit) + self.form_layout.addRow(self.label_txlabel, self.label_line_edit) + self.form_layout.addRow(self.amount_label, self.amount_layout) - if not allow_edit: - self.address_line_edit.setReadOnly(True) - self.label_line_edit.setReadOnly(True) - self.amount_spin_box.setReadOnly(True) - - self.address_line_edit.input_field.textChanged.connect(self.format_address_field) - self.address_line_edit.input_field.textChanged.connect(self.set_label_placeholder_text) - self.address_line_edit.input_field.textChanged.connect(self.check_if_used) + self.address_edit.setReadOnly(not allow_edit) + self.amount_spin_box.setReadOnly(not allow_edit) + self.label_line_edit.setReadOnly(not allow_label_edit) self.updateUi() + + # signals self.signals.language_switch.connect(self.updateUi) + self.address_edit.signal_text_change.connect(self.autofill_label) + self.address_edit.signal_bip21_input.connect(self.on_handle_input) + self.label_line_edit.signal_enterPressed.connect(self.on_label_edited) + if dismiss_label_on_focus_loss: + self.label_line_edit.signal_textEditedAndFocusLost.connect( + lambda: self.label_line_edit.setText(self.label_line_edit.originalText) + ) + + def on_label_edited(self) -> None: + wallet = self.address_edit.get_wallet_of_address() + if not wallet: + return + address = self.address_edit.address + wallet.labels.set_addr_label(address, self.label_line_edit.text().strip(), timestamp="now") + self.signals.labels_updated.emit( + UpdateFilter( + addresses=[address], + txids=wallet.get_involved_txids(address), + ) + ) + + def on_handle_input(self, data: Data, parent: QWidget) -> None: + if data.data_type == DataType.Bip21: + if data.data.get("address"): + self.address_edit.address = data.data.get("address") + if data.data.get("amount"): + self.amount_spin_box.setValue(data.data.get("amount")) + if data.data.get("label"): + self.label_line_edit.setText(data.data.get("label")) def updateUi(self) -> None: @@ -186,10 +209,9 @@ def updateUi(self) -> None: self.label_line_edit.setPlaceholderText(self.tr("Enter label here")) self.send_max_button.setText(self.tr("Send max")) - self.address_line_edit.setPlaceholderText(self.tr("Enter address here")) - self.format_address_field() - self.set_label_placeholder_text() + self.address_edit.updateUi() + self.autofill_label() def showEvent(self, event) -> None: # this is necessary, otherwise the background color of the @@ -203,11 +225,11 @@ def on_send_max_button_click(self) -> None: @property def address(self) -> str: - return self.address_line_edit.text().strip() + return self.address_edit.address @address.setter def address(self, value: str) -> None: - self.address_line_edit.setText(value) + self.address_edit.address = value @property def label(self) -> str: @@ -227,85 +249,126 @@ def amount(self, value: int) -> None: @property def enabled(self) -> bool: - return not self.address_line_edit.isReadOnly() + return not self.address_edit.isReadOnly() @enabled.setter def enabled(self, state: bool) -> None: - self.address_line_edit.setReadOnly(not state) + self.address_edit.setReadOnly(not state) self.label_line_edit.setReadOnly(not state) self.amount_spin_box.setReadOnly(not state) self.send_max_button.setEnabled(state) - def set_label_placeholder_text(self) -> None: - wallets = get_wallets(self.signals) - - wallet_id = None - label = "" - for wallet in wallets: - if self.address in wallet.get_addresses(): - wallet_id = wallet.id - label = wallet.get_label_for_address(self.address) - if label: - break - - if wallet_id: + def autofill_label(self, *args): + wallet = self.address_edit.get_wallet_of_address() + if wallet: + label = wallet.get_label_for_address(self.address_edit.address) self.label_line_edit.setPlaceholderText(label) if not self.allow_edit: self.label_line_edit.setText(label) + else: self.label_line_edit.setPlaceholderText(self.tr("Enter label for recipient address")) - def get_wallet_of_address(self, address: str) -> Optional[Wallet]: - for wallet in get_wallets(self.signals): - if wallet.is_my_address(address): - return wallet - return None - - def check_if_used(self, *args) -> None: - wallet_of_address = self.get_wallet_of_address(self.address) - if self.allow_edit and wallet_of_address and wallet_of_address.address_is_used(self.address): - if dialog_replace_with_new_receiving_address(self.address): - # find an address that is not used yet - self.address = wallet_of_address.get_address().address.as_string() - - def format_address_field(self, *args) -> None: - palette = QtGui.QPalette() - background_color = None - - wallet_of_address = self.get_wallet_of_address(self.address) - if wallet_of_address and wallet_of_address.is_my_address(self.address): - self.setTabText(self.indexOf(self.tab), self.tr('Wallet "{id}"').format(id=wallet_of_address.id)) - - if wallet_of_address.is_change(self.address): - background_color = ColorScheme.YELLOW.as_color(background=True) - palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) - else: - background_color = ColorScheme.GREEN.as_color(background=True) - palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) - else: - palette = self.address_line_edit.input_field.style().standardPalette() - self.setTabText(self.indexOf(self.tab), self.title) - - self.address_line_edit.input_field.setPalette(palette) - self.address_line_edit.input_field.update() - self.address_line_edit.update() - logger.debug( - f"{self.__class__.__name__} format_address_field for self.address {self.address}, background_color = {background_color.name() if background_color else None}" + +class RecipientTabWidget(QTabWidget): + signal_close = pyqtSignal(QTabWidget) + + def __init__( + self, + signals: Signals, + network: bdk.Network, + allow_edit=True, + title="", + parent=None, + tab_string=None, + dismiss_label_on_focus_loss=True, + ) -> None: + super().__init__(parent=parent) + self.setTabsClosable(allow_edit) + self.title = title + self.tab_string = tab_string if tab_string else self.tr('Wallet "{id}"') + self.recipient_widget = RecipientWidget( + signals=signals, + network=network, + allow_edit=allow_edit, + parent=self, + dismiss_label_on_focus_loss=dismiss_label_on_focus_loss, ) - self.setTabBarAutoHide(not self.tabText(self.indexOf(self.tab)) and not self.allow_edit) + self.addTab(self.recipient_widget, title) + self.tabCloseRequested.connect(lambda: self.signal_close.emit(self)) -class Recipients(QtWidgets.QWidget): - signal_added_recipient = QtCore.pyqtSignal(RecipientGroupBox) - signal_removed_recipient = QtCore.pyqtSignal(RecipientGroupBox) - signal_clicked_send_max_button = QtCore.pyqtSignal(RecipientGroupBox) - signal_amount_changed = QtCore.pyqtSignal(RecipientGroupBox) + self.recipient_widget.address_edit.signal_text_change.connect(self.autofill_tab_text) + + def updateUi(self) -> None: + self.recipient_widget.updateUi() + self.autofill_tab_text() - def __init__(self, signals: Signals, network: bdk.Network, allow_edit=True) -> None: + def showEvent(self, event) -> None: + # this is necessary, otherwise the background color of the + # address_line_edit.input_field is not updated properly when setting the adddress + self.updateUi() + + @property + def address(self) -> str: + return self.recipient_widget.address + + @address.setter + def address(self, value: str) -> None: + self.recipient_widget.address = value + + @property + def label(self) -> str: + return self.recipient_widget.label + + @label.setter + def label(self, value: str) -> None: + self.recipient_widget.label = value + + @property + def amount(self) -> int: + return self.recipient_widget.amount + + @amount.setter + def amount(self, value: int) -> None: + self.recipient_widget.amount = value + + @property + def enabled(self) -> bool: + return not self.recipient_widget.enabled + + @enabled.setter + def enabled(self, state: bool) -> None: + self.recipient_widget.enabled = state + + def autofill_tab_text(self, *args): + wallet = self.recipient_widget.address_edit.get_wallet_of_address() + if wallet: + self.setTabText(self.indexOf(self.recipient_widget), self.tab_string.format(id=wallet.id)) + self.setTabBarAutoHide( + not self.tabText(self.indexOf(self.recipient_widget)) and not self.recipient_widget.allow_edit + ) + else: + self.setTabText(self.indexOf(self.recipient_widget), self.title) + self.setTabBarAutoHide( + not self.tabText(self.indexOf(self.recipient_widget)) and not self.recipient_widget.allow_edit + ) + + +class Recipients(QtWidgets.QWidget): + signal_added_recipient = pyqtSignal(RecipientTabWidget) + signal_removed_recipient = pyqtSignal(RecipientTabWidget) + signal_clicked_send_max_button = pyqtSignal(RecipientTabWidget) + signal_amount_changed = pyqtSignal(RecipientTabWidget) + + def __init__( + self, signals: Signals, network: bdk.Network, allow_edit=True, dismiss_label_on_focus_loss=False + ) -> None: super().__init__() self.signals = signals self.allow_edit = allow_edit self.network = network + self.dismiss_label_on_focus_loss = dismiss_label_on_focus_loss self.main_layout = QtWidgets.QVBoxLayout(self) self.main_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) @@ -334,23 +397,24 @@ def updateUi(self) -> None: self.recipient_list.setToolTip(self.tr("Recipients")) self.add_recipient_button.setText(self.tr("+ Add Recipient")) - def add_recipient(self, recipient: Recipient = None) -> RecipientGroupBox: + def add_recipient(self, recipient: Recipient = None) -> RecipientTabWidget: if recipient is None: recipient = Recipient("", 0) - recipient_box = RecipientGroupBox( + recipient_box = RecipientTabWidget( self.signals, network=self.network, allow_edit=self.allow_edit, title="Recipient" if self.allow_edit else "", + dismiss_label_on_focus_loss=self.dismiss_label_on_focus_loss, ) recipient_box.address = recipient.address recipient_box.amount = recipient.amount if recipient.checked_max_amount: - recipient_box.send_max_button.click() + recipient_box.recipient_widget.send_max_button.click() if recipient.label: recipient_box.label = recipient.label recipient_box.signal_close.connect(self.remove_recipient_widget) - recipient_box.amount_spin_box.valueChanged.connect( + recipient_box.recipient_widget.amount_spin_box.valueChanged.connect( lambda *args: self.signal_amount_changed.emit(recipient_box) ) @@ -364,13 +428,13 @@ def insert_before_button(new_widget: QWidget) -> None: insert_before_button(recipient_box) - recipient_box.send_max_button.clicked.connect( + recipient_box.recipient_widget.send_max_button.clicked.connect( lambda: self.signal_clicked_send_max_button.emit(recipient_box) ) self.signal_added_recipient.emit(recipient_box) return recipient_box - def remove_recipient_widget(self, recipient_box: RecipientGroupBox) -> None: + def remove_recipient_widget(self, recipient_box: RecipientTabWidget) -> None: recipient_box.close() recipient_box.setParent(None) self.recipient_list.content_widget.layout().removeWidget(recipient_box) @@ -384,7 +448,7 @@ def recipients(self) -> List[Recipient]: recipient_box.address, recipient_box.amount, recipient_box.label if recipient_box.label else None, - checked_max_amount=recipient_box.send_max_button.isChecked(), + checked_max_amount=recipient_box.recipient_widget.send_max_button.isChecked(), ) for recipient_box in self.get_recipient_group_boxes() ] @@ -398,5 +462,5 @@ def recipients(self, recipient_list: List[Recipient]) -> None: for recipient in recipient_list: self.add_recipient(recipient) - def get_recipient_group_boxes(self) -> List[RecipientGroupBox]: - return self.recipient_list.findChildren(RecipientGroupBox) + def get_recipient_group_boxes(self) -> List[RecipientTabWidget]: + return self.recipient_list.findChildren(RecipientTabWidget) diff --git a/bitcoin_safe/gui/qt/tutorial.py b/bitcoin_safe/gui/qt/tutorial.py index 82319f4..3570009 100644 --- a/bitcoin_safe/gui/qt/tutorial.py +++ b/bitcoin_safe/gui/qt/tutorial.py @@ -1017,12 +1017,8 @@ def __init__( self.set_custom_widget(i, widget) if self.qt_wallet: - step = ( - self.count() - 1 - if self.qt_wallet.wallet.tutorial_index is None - else self.qt_wallet.wallet.tutorial_index - ) - self.set_current_index(step) + if self.qt_wallet.wallet.tutorial_index is not None: + self.set_current_index(self.qt_wallet.wallet.tutorial_index) # save after every step self.signal_set_current_widget.connect(lambda widget: self.qt_wallet.save()) diff --git a/bitcoin_safe/gui/qt/tx_signing_steps.py b/bitcoin_safe/gui/qt/tx_signing_steps.py index 8962f60..84b7aef 100644 --- a/bitcoin_safe/gui/qt/tx_signing_steps.py +++ b/bitcoin_safe/gui/qt/tx_signing_steps.py @@ -31,7 +31,7 @@ from typing import Dict, List, Optional, Type import bdkpython as bdk -from bitcoin_qr_tools.data import Data, DataType +from bitcoin_qr_tools.data import Data from bitcoin_safe.gui.qt.export_data import ( DataGroupBox, @@ -167,7 +167,7 @@ def create_export_widget(self, signature_importers: List[AbstractSignatureImport ) export_widget = ExportDataSimple( - data=Data(self.psbt, DataType.PSBT), + data=Data.from_psbt(self.psbt), sync_tabs={ wallet_id: qt_wallet.sync_tab for wallet_id, qt_wallet in self.signals.get_qt_wallets().items() diff --git a/bitcoin_safe/gui/qt/ui_tx.py b/bitcoin_safe/gui/qt/ui_tx.py index 965a47e..2894595 100644 --- a/bitcoin_safe/gui/qt/ui_tx.py +++ b/bitcoin_safe/gui/qt/ui_tx.py @@ -89,7 +89,7 @@ from ...util import Satoshis, block_explorer_URL, format_fee_rate, serialized_to_hex from ...wallet import ToolsTxUiInfo, TxStatus, Wallet, get_wallets from .category_list import CategoryList -from .recipients import RecipientGroupBox, Recipients +from .recipients import Recipients, RecipientTabWidget from .util import ( Message, MessageType, @@ -107,8 +107,15 @@ def __init__(self, config: UserConfig, signals: Signals, mempool_data: MempoolDa self.mempool_data = mempool_data self.config = config - def create_recipients(self, layout: QLayout, parent=None, allow_edit=True) -> Recipients: - recipients = Recipients(self.signals, network=self.config.network, allow_edit=allow_edit) + def create_recipients( + self, layout: QLayout, parent=None, allow_edit=True, dismiss_label_on_focus_loss=True + ) -> Recipients: + recipients = Recipients( + self.signals, + network=self.config.network, + allow_edit=allow_edit, + dismiss_label_on_focus_loss=dismiss_label_on_focus_loss, + ) layout.addWidget(recipients) recipients.setMinimumWidth(250) @@ -180,7 +187,9 @@ def __init__( self.tabs_inputs_outputs.addTab(self.tab_outputs, "") self.tabs_inputs_outputs.setCurrentWidget(self.tab_outputs) - self.recipients = self.create_recipients(self.tab_outputs.layout(), allow_edit=False) + self.recipients = self.create_recipients( + self.tab_outputs.layout(), allow_edit=False, dismiss_label_on_focus_loss=True + ) # right side bar self.right_sidebar = QWidget() @@ -710,7 +719,7 @@ def set_tx( confirmation_time: bdk.BlockTime = None, sent_amount: int = None, ) -> None: - self.data = Data(tx, DataType.Tx) + self.data = Data.from_tx(tx) self.fee_info = fee_info if fee_info is not None: @@ -781,7 +790,7 @@ def set_psbt(self, psbt: bdk.PartiallySignedTransaction, fee_info: FeeInfo = Non fee_rate (_type_, optional): This is the exact fee_rate chosen in txbuilder. If not given it has to be estimated with estimate_segwit_tx_size_from_psbt. """ - self.data = Data(psbt, DataType.PSBT) + self.data = Data.from_psbt(psbt) self.fee_info = fee_info # if fee_rate is set, it means the @@ -872,7 +881,9 @@ def __init__( self.widget_middle.layout().addWidget(self.balance_label) - self.recipients: Recipients = self.create_recipients(self.widget_middle.layout()) + self.recipients: Recipients = self.create_recipients( + self.widget_middle.layout(), dismiss_label_on_focus_loss=False + ) self.recipients.signal_clicked_send_max_button.connect(self.updateUi) self.recipients.add_recipient() @@ -1146,12 +1157,12 @@ def get_ui_tx_infos(self, use_this_tab=None) -> TxUiInfos: def reapply_max_amounts(self) -> None: recipient_group_boxes = self.recipients.get_recipient_group_boxes() for recipient_group_box in recipient_group_boxes: - recipient_group_box.amount_spin_box.setMaximum(self.get_total_input_value()) + recipient_group_box.recipient_widget.amount_spin_box.setMaximum(self.get_total_input_value()) recipient_group_boxes_max_checked = [ recipient_group_box for recipient_group_box in recipient_group_boxes - if recipient_group_box.send_max_button.isChecked() + if recipient_group_box.recipient_widget.send_max_button.isChecked() ] total_change_amount = self.get_total_change_amount(include_max_checked=False) for recipient_group_box in recipient_group_boxes_max_checked: @@ -1179,10 +1190,10 @@ def get_total_change_amount(self, include_max_checked=False) -> int: total_change_amount = total_input_value - total_output_value return total_change_amount - def set_max_amount(self, recipient_group_box: RecipientGroupBox, max_amount: int) -> None: + def set_max_amount(self, recipient_group_box: RecipientTabWidget, max_amount: int) -> None: with BlockChangesSignals([recipient_group_box]): - recipient_group_box.amount_spin_box.setValue(max_amount) + recipient_group_box.recipient_widget.amount_spin_box.setValue(max_amount) def tab_changed(self, index: int) -> None: # pyqtSlot called when the current tab changes diff --git a/bitcoin_safe/logging_handlers.py b/bitcoin_safe/logging_handlers.py index 8087ceb..59cf1a3 100644 --- a/bitcoin_safe/logging_handlers.py +++ b/bitcoin_safe/logging_handlers.py @@ -32,6 +32,8 @@ import platform import sys +from bitcoin_safe import __version__ + from .simple_mailer import compose_email @@ -46,7 +48,7 @@ def remove_absolute_paths(line: str) -> str: def mail_error_repot(error_report: str) -> None: email = "andreasgriffin@proton.me" - subject = "Error report" + subject = f"Error report - Bitcoin Safe Version: {__version__}" body = f"""Error: {error_report} """.replace( @@ -56,7 +58,8 @@ def mail_error_repot(error_report: str) -> None: # Write additional system info if needed body += "\n\nSystem Info:\n" body += f"OS: {platform.platform()}\n" - body += f"Python Version: {sys.version}\n\n" + body += f"Python Version: {sys.version}\n" + body += f"Bitcoin Safe Version: {__version__}\n\n" return compose_email(email, subject, body) diff --git a/bitcoin_safe/signer.py b/bitcoin_safe/signer.py index d9f28df..95b58f3 100644 --- a/bitcoin_safe/signer.py +++ b/bitcoin_safe/signer.py @@ -332,7 +332,7 @@ def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptio try: signed_psbt = self.usb.sign(psbt) if signed_psbt: - self.scan_result_callback(psbt, Data(signed_psbt, DataType.PSBT)) + self.scan_result_callback(psbt, Data.from_psbt(signed_psbt)) except Exception as e: if "multisig" in str(e).lower(): question_dialog( diff --git a/poetry.lock b/poetry.lock index b667a06..4093baa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -110,13 +110,13 @@ requests = ">=2.31.0,<3.0.0" [[package]] name = "bitcoin-qr-tools" -version = "0.10.6" +version = "0.10.8" description = "Python bitcoin qr reader and generator" optional = false python-versions = "<3.12,>=3.8" files = [ - {file = "bitcoin_qr_tools-0.10.6-py3-none-any.whl", hash = "sha256:858b4b43411de952b900bb999d4c47fc1b4a7f4613537a29fe84210eddcc8769"}, - {file = "bitcoin_qr_tools-0.10.6.tar.gz", hash = "sha256:b72cb70778b4e8b62ca77aaea45f5e0be9a79c87552fae016ae004b54bfb5fca"}, + {file = "bitcoin_qr_tools-0.10.8-py3-none-any.whl", hash = "sha256:e779d2e6abb772ea09101f207298b9736a531f4dd916c0bc3fef05b79f867236"}, + {file = "bitcoin_qr_tools-0.10.8.tar.gz", hash = "sha256:287dea0a583a6e054a7c8fc6bf91237724274e4bf90047da7ab655caaa146b4d"}, ] [package.dependencies] @@ -2401,4 +2401,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "33c3015ecdecb5d0c6cd68390ebb1c9abfb3c59d65dcced7fa58a5e505b95ae0" +content-hash = "2f05f12fff927ce9ebe60f16177071dc1932aea8b449dc6d7149c02ae4105706" diff --git a/pyproject.toml b/pyproject.toml index 9cf2357..7d46d5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ show_error_codes = true [tool.poetry] name = "bitcoin-safe" -version = "0.7.1a0" +version = "0.7.2a0" description = "Long-term Bitcoin savings made Easy" authors = [ "andreasgriffin ",] license = "GPL-3.0" @@ -48,7 +48,7 @@ pyqt6-charts = "^6.6.0" electrumsv-secp256k1 = "^18.0.0" python-gnupg = "^0.5.2" bitcoin-nostr-chat = "^0.2.3" -bitcoin-qr-tools = "^0.10.6" +bitcoin-qr-tools = "^0.10.8" bitcoin-usb = "^0.1.16" [tool.briefcase.app.bitcoin-safe] diff --git a/tests/gui/qt/test_gui_setup_wallet.py b/tests/gui/qt/test_gui_setup_wallet.py index 06ebde3..5bfec6f 100644 --- a/tests/gui/qt/test_gui_setup_wallet.py +++ b/tests/gui/qt/test_gui_setup_wallet.py @@ -269,9 +269,11 @@ def page7() -> None: assert [recipient.address for recipient in qt_wallet.uitx_creator.recipients.recipients] == [ "bcrt1qmx7ke6j0amadeca65xqxpwh0utju5g3uka2sj5" ] - assert box.address_line_edit.text() == "bcrt1qmx7ke6j0amadeca65xqxpwh0utju5g3uka2sj5" + assert box.address == "bcrt1qmx7ke6j0amadeca65xqxpwh0utju5g3uka2sj5" assert ( - box.address_line_edit.input_field.palette().color(QtGui.QPalette.ColorRole.Base).name() + box.recipient_widget.address_edit.input_field.palette() + .color(QtGui.QPalette.ColorRole.Base) + .name() == "#8af296" ) assert qt_wallet.uitx_creator.recipients.recipients[0].amount == amount diff --git a/tests/test_signers.py b/tests/test_signers.py index cf3d31f..9eb0d47 100644 --- a/tests/test_signers.py +++ b/tests/test_signers.py @@ -35,7 +35,7 @@ import bdkpython as bdk import pytest from _pytest.logging import LogCaptureFixture -from bitcoin_qr_tools.data import Data, DataType +from bitcoin_qr_tools.data import Data from pytestqt.qtbot import QtBot from bitcoin_safe.signer import SignatureImporterClipboard @@ -283,7 +283,7 @@ def test_signer_finalizes_ofn_final_sig_receive( with qtbot.waitSignal(signer.signal_final_tx_received, timeout=1000) as blocker: signer.scan_result_callback( original_psbt=bdk.PartiallySignedTransaction(psbt_1_sig_2_of_3), - data=Data(bdk.PartiallySignedTransaction(psbt_second_signature_2_of_3), DataType.PSBT), + data=Data.from_psbt(bdk.PartiallySignedTransaction(psbt_second_signature_2_of_3)), ) # Now check the argument with which the signal was emitted @@ -310,7 +310,7 @@ def test_signer_recognizes_finalized_tx_received( with qtbot.waitSignal(signer.signal_final_tx_received, timeout=1000) as blocker: signer.scan_result_callback( original_psbt=bdk.PartiallySignedTransaction(psbt_1_sig_2_of_3), - data=Data(bdk.Transaction(hex_to_serialized(fully_signed_tx)), DataType.Tx), + data=Data.from_tx(bdk.Transaction(hex_to_serialized(fully_signed_tx))), ) # Now check the argument with which the signal was emitted diff --git a/tools/release.py b/tools/release.py index 2f9436c..b8b31d9 100644 --- a/tools/release.py +++ b/tools/release.py @@ -29,7 +29,9 @@ import datetime import getpass +import hashlib import json +import os import subprocess import sys from pathlib import Path @@ -57,7 +59,7 @@ def get_default_description(latest_tag: str): gpg --verify Bitcoin-Safe-{latest_tag}-x86_64.AppImage.asc ``` -#### Install on Mac, Linux, or Windows +#### Install and run on Mac, Linux, or Windows ``` python3 -m pip install bitcoin-safe python3 -m bitcoin_safe @@ -91,6 +93,82 @@ def get_latest_git_tag() -> Optional[str]: return None +def get_default_remote(): + try: + # Retrieve the list of remotes and their URLs + result = subprocess.run(["git", "remote", "-v"], check=True, text=True, capture_output=True) + remote_info = result.stdout.splitlines() + + # Parse the first remote name from the output + default_remote = remote_info[0].split()[0] + return default_remote + except subprocess.CalledProcessError as e: + print(f"Failed to retrieve remote information: {e}") + return None + + +def add_and_publish_git_tag(tag): + try: + + # Add the tag + subprocess.run(["git", "tag", tag], check=True) + + # Get the default remote name + remote_name = get_default_remote() + if remote_name: + # Push the tag to the default remote + print(f"Tag '{tag}' pushing to remote '{remote_name}'") + subprocess.run(["git", "push", remote_name, tag], check=True) + print(f"Tag '{tag}' successfully pushed to remote '{remote_name}'") + else: + print("Could not determine default remote.") + except subprocess.CalledProcessError as e: + print(f"Failed to push tag '{tag}': {e}") + + +def create_pypi_wheel(dist_dir="dist") -> Tuple[str, str]: + """_summary_ + + Returns: + Tuple[str, str]: (whl_file, hash_value) + """ + + def run_poetry_build(): + # Run `poetry build` + subprocess.run(["poetry", "build"], check=True) + + def get_whl_file(): + # Locate the .whl file in the dist directory + for filename in os.listdir(dist_dir): + if filename.endswith(".whl"): + return os.path.join(dist_dir, filename) + return None + + def calculate_sha256(file_path): + # Calculate the SHA-256 hash of the file + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + return sha256_hash.hexdigest() + + run_poetry_build() + + whl_file = get_whl_file() + if not whl_file: + raise Exception("No .whl file found in the dist directory.") + + hash_value = calculate_sha256(whl_file) + pip_install_command = f"pip install {Path(dist_dir)/whl_file} --hash=sha256:{hash_value}" + print(pip_install_command) + return whl_file, hash_value + + +def publish_pypi_wheel(dist_dir="dist"): + whl_file, hash_value = create_pypi_wheel(dist_dir=dist_dir) + subprocess.run(["poetry", "publish"], check=True) + + def create_github_release( token: str, owner: str, @@ -168,41 +246,49 @@ def main() -> None: owner = "andreasgriffin" repo = "bitcoin-safe" - latest_tag: Optional[str] = get_latest_git_tag() - if latest_tag is None: - return + from bitcoin_safe import __version__ - print(f"The latest Git tag is: {latest_tag}") - if ( - get_input_with_default(f"Is this the correct tag for the release {latest_tag}? (y/n): ", "y").lower() + latest_tag: Optional[str] = get_latest_git_tag() + if latest_tag == __version__ and ( + get_input_with_default( + f"The tag {latest_tag} exists already. Do you want to continue? (y/n): ", "n" + ).lower() != "y" ): - print("Release creation aborted.") return + if get_input_with_default(f"Is this version {__version__} correct? (y/n): ", "y").lower() != "y": + return + + if latest_tag != __version__: + add_and_publish_git_tag(__version__) + directory = Path("dist") files = list_directory_files(directory) print("Files to be uploaded:") for file_path, size, modified_time in files: print(f" {file_path.name} - {size} bytes, last modified: {modified_time}") - if get_input_with_default("Are these the correct files to upload? (y/n): ", "y").lower() == "y": - release_name = get_input_with_default("Enter the release name", f"{latest_tag}") - body = get_default_description(latest_tag=latest_tag) - draft = get_input_with_default("Is this a draft release?", "y").lower() == "y" - prerelease = get_input_with_default("Is this a prerelease?", "n").lower() == "y" - - token = getpass.getpass("Enter your GitHub token: ") - release_result = create_github_release( - token, owner, repo, latest_tag, release_name, body, draft, prerelease - ) - print("Release created successfully:", json.dumps(release_result, indent=2)) - - for file_path, _, _ in files: - upload_release_asset(token, owner, repo, release_result["id"], file_path) - print(f"Asset {file_path.name} uploaded successfully.") - else: + if not get_input_with_default("Are these the correct files to upload? (y/n): ", "y").lower() == "y": print("Asset upload aborted.") + return + + release_name = get_input_with_default("Enter the release name", f"{__version__}") + body = get_default_description(latest_tag=__version__) + draft = get_input_with_default("Is this a draft release?", "y").lower() == "y" + prerelease = get_input_with_default("Is this a prerelease?", "n").lower() == "y" + token = getpass.getpass("Enter your GitHub token: ") + release_result = create_github_release( + token, owner, repo, __version__, release_name, body, draft, prerelease + ) + print("Release created successfully:", json.dumps(release_result, indent=2)) + + for file_path, _, _ in files: + upload_release_asset(token, owner, repo, release_result["id"], file_path) + print(f"Asset {file_path.name} uploaded successfully.") + + if get_input_with_default("Publish pypi package? (y/n): ", "y").lower() == "y": + publish_pypi_wheel(dist_dir=directory) if __name__ == "__main__":