diff --git a/bal/gui/__init__.py b/bal/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bal/gui/qt/__init__.py b/bal/gui/qt/__init__.py new file mode 100644 index 0000000..c427e8b --- /dev/null +++ b/bal/gui/qt/__init__.py @@ -0,0 +1,17 @@ +""" +bal.gui.qt +========== + +The PyQt6 graphical interface of the Bitcoin After Life plugin. + +Module map (was previously one 4000-line ``qt.py``): + + common.py - shared imports + tiny helpers (shown_cv, add_widget, ...) + theme.py - colour mapping for will-item statuses (was WillItem.get_color) + calendar.py - .ics calendar generation + widgets.py - reusable leaf widgets (editors, checkboxes, will box, ...) + dialogs.py - all dialogs (settings, wizard, build-will, detail, ...) + lists.py - tree views (heirs, preview, will-executors) + window.py - BalWindow controller (one per wallet window) + plugin.py - Plugin class with the Electrum @hook methods (entry point) +""" diff --git a/bal/gui/qt/calendar.py b/bal/gui/qt/calendar.py new file mode 100644 index 0000000..02a9ac9 --- /dev/null +++ b/bal/gui/qt/calendar.py @@ -0,0 +1,160 @@ +""" +bal.gui.qt.calendar +=================== + +iCalendar (.ics) generation and "open with default calendar app" helper. + +When a will is built, the plugin can create a calendar event reminding the user +to "check in" before the locktime expires. This module turns the event data +into an RFC-5545 .ics file and opens it with the OS default application. +""" + +from .common import * +from .common import _, _logger # underscore names are not re-exported by "import *" + +class BalCalendar: + @staticmethod + def write_temp_ics(content): + fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics") + with os.fdopen(fd, "wb") as f: + f.write(content.encode("utf-8")) + return path + + @staticmethod + def open_with_default_app(calendar_app, path): + _logger.debug("opening calendar app") + try: + subprocess.check_call([calendar_app, path]) + return True + except Exception as e: + _logger.error(f"starting calendar app {e}") + return False + + + @staticmethod + def format_time(time): + return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + #return time.astimezone(timezone.utc).strftime("%Y%m%d") + + @staticmethod + def compute_event_timestamp(will_settings_locktime, min_valid_tx_locktime): + """Return the UNIX timestamp the calendar event should start at. + + The reminder must fire at the EARLIEST moment the inheritance could be + executed, i.e. the lower value between: + + * ``will_settings_locktime`` - the deadline configured in the will + settings, and + * ``min_valid_tx_locktime`` - the locktime of the valid will + transaction that expires first (``None`` when there is no valid + transaction yet). + + When there is no valid transaction the will-settings locktime is used. + """ + ws = int(will_settings_locktime) + if min_valid_tx_locktime is None: + return ws + return min(ws, int(min_valid_tx_locktime)) + + @staticmethod + def build_alarms(num_alarms, alarm_start, alarm_end): + """Build RFC-5545 ``VALARM`` blocks spread before the deadline. + + The window between ``alarm_start`` (the later of "now" and the + check-alive threshold) and ``alarm_end`` (the deadline / event time) is + divided into ``num_alarms + 1`` equal slices; an alarm is placed at each + of the first ``num_alarms`` division points. Each alarm uses a + ``TRIGGER;RELATED=END`` negative duration so it fires *before* the event + end, which every major online calendar (Google, Apple, Outlook) + understands. + + Args: + num_alarms: how many reminders to create (0 -> no alarms). + alarm_start: ``datetime`` when reminders should start being useful. + alarm_end: ``datetime`` of the deadline (the event end). + + Returns: + A list of .ics lines (``BEGIN:VALARM`` ... ``END:VALARM`` blocks). + """ + if num_alarms < 1: + return [] + total_seconds = max(1, int((alarm_end - alarm_start).total_seconds())) + # Divide the total time by (num_alarms + 1) and place alarms on the + # first num_alarms division points (measured back from the deadline). + interval = total_seconds // (num_alarms + 1) + lines = [] + for i in range(1, num_alarms + 1): + # Offset measured from the END (deadline): the closest alarm to the + # deadline is the smallest offset. + offset_seconds = interval * (num_alarms + 1 - i) + trigger = BalCalendar._duration_trigger(offset_seconds) + lines.extend([ + "BEGIN:VALARM", + f"TRIGGER;RELATED=END:-{trigger}", + "ACTION:DISPLAY", + "DESCRIPTION:Bitcoin After Life reminder", + "END:VALARM", + ]) + return lines + + @staticmethod + def _duration_trigger(offset_seconds): + """Format ``offset_seconds`` as an RFC-5545 duration (e.g. ``P3D``). + + Uses the coarsest sensible unit so the value stays readable and within + what calendar clients expect: days (``PD``), hours (``PTH``), + minutes (``PTM``) or seconds (``PTS``). + """ + if offset_seconds <= 0: + return "PT0S" + if offset_seconds >= 86400: + return f"P{offset_seconds // 86400}D" + if offset_seconds >= 3600: + return f"PT{offset_seconds // 3600}H" + if offset_seconds >= 60: + return f"PT{offset_seconds // 60}M" + return f"PT{offset_seconds}S" + + @staticmethod + def ical_escape(text: str) -> str: + # Escape special characters per RFC 5545: backslash, ";", ",", newlines. + text = text.encode("utf-8") + text = ( + text.replace(b"\\", b"\\\\") + .replace(b";", b"\\;") + .replace(b",", b"\\,") + ) + out ="" + temp=text.split(b"\r\n") + for s in temp: + encoded= s + cut =0 + while len(encoded) >75: + cut+=5 + encoded=f"{s[:len(s)-cut]}" + if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\": + cut += 1 + encoded=f"{s[:len(s)-cut]}" + encoded=f"{encoded}...\r\n".encode("utf-8") + if cut>0: + out+=str(f"{s[:len(s)-cut].decode()}...\r\n") + else: + out+=str(f"{s.decode()}\r\n") + + return out[:-2] + + @staticmethod + def fold_ical_line(line: str, limit: int = 75) -> str: + # Return the line folded per RFC 5545: split into CRLF-separated chunks + # with a leading space on every continuation line. + encoded = line.encode("utf-8") + parts = [] + while len(encoded) > limit: + # Cut without splitting a multi-byte UTF-8 character. + cut = limit + while (encoded[cut] & 0xC0) == 0x80: # UTF-8 continuation byte + cut -= 1 + parts.append(encoded[:cut].decode("utf-8")) + encoded = encoded[cut:] + parts.append(encoded.decode("utf-8")) + return "\r\n ".join(parts) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py new file mode 100644 index 0000000..044795c --- /dev/null +++ b/bal/gui/qt/common.py @@ -0,0 +1,173 @@ +""" +bal.gui.qt.common +================= + +Shared imports and tiny helper utilities for the Qt GUI layer. + +Every other ``bal.gui.qt`` module does ``from .common import *`` so that the +long list of Electrum / PyQt6 imports lives in a single place. This file also +hosts a few GUI helpers that do not deserve a module of their own: + + * :class:`shown_cv` - trivial mutable "is this tab shown?" holder. + * :func:`add_widget` - add a labelled widget (plus optional help) to a grid. + * :func:`log_error` - format an exception traceback for a dialog. + * :func:`export_meta_gui` - export plugin metadata to a JSON file. + * :class:`CheckAliveError`- raised when the "check alive" date is in the past. +""" + +import copy +import enum +import os +import subprocess +import tempfile +import time +import traceback +from datetime import datetime, timezone +from decimal import Decimal +from functools import partial +from typing import Any, Callable, Mapping, Optional, Union + +from electrum.bitcoin import (NLOCKTIME_MAX, + NLOCKTIME_MIN) +from electrum.gui.qt.amountedit import BTCAmountEdit +from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton +from electrum.gui.qt.my_treeview import MyTreeView +from electrum.gui.qt.password_dialog import PasswordDialog +from electrum.gui.qt.transaction_dialog import TxDialog +from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme, + EnterButton, HelpButton, MessageBoxMixin, + OkButton, TaskThread, WindowModalDialog, + char_width_in_lineedit, getSaveFileName, + import_meta_gui, read_QIcon_from_bytes, + read_QPixmap_from_bytes) +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError +from electrum.payment_identifier import PaymentIdentifier +from electrum.plugin import hook +from electrum.transaction import SerializationError, Transaction, tx_from_any +from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled, + decimal_point_to_base_unit_name, read_json_file, + write_json_file) +from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize, + Qt, QTimer, pyqtSignal) +from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem, + QStandardItemModel) +from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox, + QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout, + QLabel, QLineEdit, QTextEdit, QMenu, QMenuBar, + QPushButton, QScrollArea, QSizePolicy, QSpinBox, + QStackedWidget, QStyle, QStyleOptionFrame, + QVBoxLayout, QWidget, QDialog) + +# --- Core (GUI-free) logic layer --- +from ...core.plugin_base import BalPlugin, BalTimestamp +from ...core.heirs import HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT, Heirs +from ...core.util import Util +from ...core.will import (AmountException, HeirChangeException, + HeirNotFoundException, NoHeirsException, + NotCompleteWillException, NoWillExecutorNotPresent, + TxFeesChangedException, Will, + WillexecutorChangeException, WillExecutorNotPresent, + WillExpiredException, WillItem, WillPostponedException) +from ...core.willexecutors import Willexecutors + +# --- Presentation helpers --- +from .theme import server_status_text, server_status_tooltip, status_color +from .window_utils import (bring_to_front, show_modal, show_on_top, + stop_thread, top_level_of) + +_logger = get_logger(__name__) + + +class shown_cv: + _type = bool + + def __init__(self, value): + self.value = value + + def get(self): + return self.value + + def set(self, value): + self.value = value + + + + +def add_widget(grid, label, widget, row, help_): + grid.addWidget(QLabel(_(label)), row, 0) + grid.addWidget(widget, row, 1) + grid.addWidget(HelpButton(help_), row, 2) + + + + +class CheckAliveError(Exception): + def __init__(self, timestamp_to_check): + self.timestamp_to_check = timestamp_to_check + + def __str__(self): + return "Check alive expired please update it: {}".format( + datetime.fromtimestamp(self.timestamp_to_check).isoformat() + ) + + + + +def log_error(exec_info, window=None): + """Log an error and optionally show it. + + ``exec_info`` may be either a ``sys.exc_info()`` triple + ``(type, value, traceback)`` or a single exception instance (callers use + both forms), so we handle both and always try to log a full traceback. + """ + _logger.error(f"LOG_ERROR: {exec_info}") + exc = None + if isinstance(exec_info, BaseException): + exc = exec_info + elif isinstance(exec_info, (tuple, list)) and len(exec_info) >= 2: + # sys.exc_info() form: the exception instance is the 2nd element. + exc = exec_info[1] + try: + if exc is not None: + _logger.error( + "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ) + ) + else: + _logger.error(traceback.format_exc()) + except Exception: + _logger.error(traceback.format_exc()) + + if window is not None: + # show_error expects a human-readable message, not a triple. + window.show_error(str(exc) if exc is not None else str(exec_info)) + + + + +def export_meta_gui(electrum_window, title, exporter): + filter_ = "All files (*)" + filename = getSaveFileName( + parent=electrum_window, + title=_("Select file to save your {}".format(title)), + filename="BALplugin_{}_{}_{}".format( + BalPlugin.chainname, str(electrum_window.wallet), title + ), + filter=filter_, + config=electrum_window.config, + ) + if not filename: + return + try: + exporter(filename) + except FileExportFailed as e: + electrum_window.show_critical(str(e)) + else: + electrum_window.show_message( + _("Your {0} were exported to '{1}'".format(title, str(filename))) + ) + + diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py new file mode 100644 index 0000000..663fccd --- /dev/null +++ b/bal/gui/qt/dialogs.py @@ -0,0 +1,1355 @@ +""" +bal.gui.qt.dialogs +================== + +All modal/non-modal dialogs of the plugin. + + * BalDialog - common base dialog (icon, close handling). + * BalWizard* (Dialog/Widget) - the step-by-step "create your will" wizard. + * BalWaitingDialog / + BalBlockingWaitingDialog - progress dialogs for background tasks. + * BalBuildWillDialog - the central build/sign/push/broadcast flow. + * WillDetailDialog - shows the full will tree for one wallet. + * WillExecutorDialog - manage the list of will-executor servers. + +To keep the dialogs verbatim while avoiding import cycles with the list views, +the few list classes they reference are imported lazily inside the methods that +use them (see ``lists`` imports below). +""" + +from .common import * +from .common import _, _logger # underscore names are not re-exported by "import *" +from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget, + LockTimeWidget, PercAmountEdit, ThresholdTimeWidget, + WillSettingsWidget, WillWidget) +from .calendar import BalCalendar +# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are +# imported lazily where needed to avoid a dialogs<->lists import cycle. + + +class BalDialog(QDialog,MessageBoxMixin): + _stopping = False + def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"): + import signal + from PyQt6.QtCore import QMetaObject, Qt + from PyQt6.QtWidgets import QApplication + def handler(signum, frame): + QMetaObject.invokeMethod(self, "close", Qt.ConnectionType.QueuedConnection) + + #signal.signal(signal.SIGINT, handler) + # NOTE: do NOT store this as ``self.parent`` - that would shadow + # QWidget.parent() and can make the dialog disappear behind Electrum. + self._bal_parent = parent + self.thread = None + # Anchor the dialog to the *top-level* Electrum window so it always + # stays in front of it (instead of falling behind). + super().__init__(top_level_of(parent)) + if title: + self.setWindowTitle(title) + # WindowModalDialog.__init__(self,parent) + self.setWindowIcon(read_QIcon_from_bytes(bal_plugin.read_file(icon))) + + def closeEvent(self, event): + self._stopping = True + # NOTE: we deliberately do NOT stop ``self.thread`` here. + # + # Electrum's ``TaskThread`` delivers results via ``on_done`` which calls + # ``cb_done`` (often ``self.accept`` -> closes this dialog) *before* + # ``cb_result`` (``on_success`` -> e.g. updating the will-executor + # list). If we stop/join the thread inside ``closeEvent`` the close + # triggered by ``accept`` tears the thread down *before* ``on_success`` + # runs, so the downloaded data is silently dropped. The original plugin + # left this commented out for exactly this reason; subclasses that own a + # genuinely long-lived thread stop it explicitly in their own close + # handler. + super().closeEvent(event) + + def hideEvent(self, event): + self._stopping = True + super().hideEvent(event) + + +class BalWizardDialog(BalDialog): + def __init__(self, bal_window: "BalWindow"): + assert bal_window + BalDialog.__init__( + self, bal_window.window, bal_window.bal_plugin, _("Bal Wizard Setup") + ) + self.setMinimumSize(800, 400) + self.bal_window = bal_window + self._bal_parent = bal_window.window + self.layout = QVBoxLayout(self) + self.widget = BalWizardHeirsWidget( + bal_window, self, self.on_next_heir, None, self.on_cancel_heir + ) + self.layout.addWidget(self.widget) + + def next_widget(self, widget): + self.layout.removeWidget(self.widget) + self.widget.close() + self.widget = widget + self.layout.addWidget(self.widget) + # self.update() + # self.repaint() + + def on_next_heir(self): + self.next_widget( + BalWizardLocktimeAndFeeWidget( + self.bal_window, + self, + self.on_next_locktimeandfee, + self.on_previous_heir, + self.on_cancel_heir, + ) + ) + + def on_previous_heir(self): + self.next_widget( + BalWizardHeirsWidget( + self.bal_window, self, self.on_next_heir, None, self.on_cancel_heir + ) + ) + + def on_cancel_heir(self): + pass + + def on_next_wedonwload(self): + self.next_widget( + BalWizardWEWidget( + self.bal_window, + self, + self.on_next_we, + self.on_next_locktimeandfee, + self.on_cancel_heir, + ) + ) + + def on_next_we(self): + close_window = BalBuildWillDialog(self.bal_window) + close_window.build_will_task() + self.close() + # self.next_widget(BalWizardLocktimeAndFeeWidget(self.bal_window,self,self.on_next_locktimeandfee,self.on_next_wedonwload,self.on_next_wedonwload.on_cancel_heir)) + + def on_next_locktimeandfee(self): + self.next_widget( + BalWizardWEDownloadWidget( + self.bal_window, + self, + self.on_next_wedonwload, + self.on_next_heir, + self.on_cancel_heir, + ) + ) + + def on_accept(self): + self.bal_window.update_all() + pass + + def on_reject(self): + pass + + def on_close(self): + self.bal_window.update_all() + pass + + def closeEvent(self, event): + self._stopping = True + # self.bal_window.heir_list_widget.will_settings_widget.update_will_settings() + pass + + + +class BalWizardWidget(QWidget): + title = None + message = None + + def __init__( + self, bal_window: "BalWindow", parent, on_next, on_previous, on_cancel + ): + QWidget.__init__(self, parent) + self.vbox = QVBoxLayout(self) + self.bal_window = bal_window + self._bal_parent = parent + self.on_next = on_next + self.on_cancel = on_cancel + self.titleLabel = QLabel(self.title) + self.vbox.addWidget(self.titleLabel) + self.messageLabel = QLabel(_(self.message)) + self.vbox.addWidget(self.messageLabel) + + self.content = self.get_content() + self.content_container = QWidget() + self.containrelayout = QVBoxLayout(self.content_container) + self.containrelayout.addWidget(self.content) + + self.vbox.addWidget(self.content_container) + + spacer_widget = QWidget() + spacer_widget.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self.vbox.addWidget(spacer_widget) + + self.buttons = [] + if on_previous: + self.on_previous = on_previous + self.previous_button = QPushButton(_("Previous")) + self.previous_button.clicked.connect(self._on_previous) + self.buttons.append(self.previous_button) + + self.next_button = QPushButton(_("Next")) + self.next_button.clicked.connect(self._on_next) + self.buttons.append(self.next_button) + + self.abort_button = QPushButton(_("Cancel")) + self.abort_button.clicked.connect(self._on_cancel) + self.buttons.append(self.abort_button) + + self.vbox.addLayout(Buttons(*self.buttons)) + + def _on_cancel(self): + self.on_cancel() + self._bal_parent.close() + + def _on_next(self): + if self.validate(): + self.on_next() + + def _on_previous(self): + self.on_previous() + + def get_content(self): + pass + + def validate(self): + return True + + + +class BalWizardHeirsWidget(BalWizardWidget): + title = "Bitcoin After Life Heirs" + message = ( + "Please add your heirs\n remember that 100% of wallet balance will be spent" + ) + + def get_content(self): + # Lazy import to avoid a dialogs<->lists import cycle (lists imports + # BalBuildWillDialog from this module at load time). + from .lists import HeirListWidget + self.heir_list_widget = HeirListWidget(self.bal_window, self) + button_add = QPushButton(_("Add")) + button_add.clicked.connect(self.add_heir) + button_import = QPushButton(_("Import")) + button_import.clicked.connect(self.import_from_file) + button_export = QPushButton(_("Export")) + button_export.clicked.connect(self.export_to_file) + widget = QWidget() + vbox = QVBoxLayout(widget) + vbox.addWidget(self.heir_list_widget) + vbox.addLayout(Buttons(button_add, button_import, button_export)) + return widget + + def import_from_file(self): + self.bal_window.import_heirs() + self.heir_list_widget.update() + + def export_to_file(self): + self.bal_window.export_heirs() + + def add_heir(self): + self.bal_window.new_heir_dialog() + self.heir_list_widget.update() + + def validate(self): + return True + + + +class BalWizardWEDownloadWidget(BalWizardWidget): + title = _("Bitcoin After Life Will-Executors") + message = _("Choose willexecutors download method") + + def get_content(self): + # question = QLabel() + self.combo = QComboBox() + self.combo.addItems( + [ + "Automatically download and select willexecutors", + "Only download willexecutors list", + "Import willexecutor list from file", + "Manual", + ] + ) + # heir_name.setFixedWidth(32 * char_width_in_lineedit()) + return self.combo + + def validate(self): + return True + + def _on_next(self): + + index = self.combo.currentIndex() + _logger.debug(f"selected index:{index}") + if index < 3: + self.bal_window.willexecutors = Willexecutors.get_willexecutors( + self.bal_window.bal_plugin + ) + + if index == 2: + + def do_nothing(): + self.bal_window.willexecutors.update(self.willexecutors) + Willexecutors.save( + self.bal_window.bal_plugin, self.bal_window.willexecutors + ) + pass + + import_meta_gui( + self.bal_window.window, + _("willexecutors"), + self.import_json_file, + do_nothing, + ) + + if index < 2: + + def on_success(willexecutors): + def ping_on_success(result): + ping_on_done() + + def ping_on_failure(exec_info): + ping_on_done() + + def ping_on_done(): + if index < 1: + for we in self.bal_window.willexecutors: + if self.bal_window.willexecutors[we]["status"] == 200: + self.bal_window.willexecutors[we]["selected"] = True + Willexecutors.save( + self.bal_window.bal_plugin, self.bal_window.willexecutors + ) + + self.bal_window.ping_willexecutors( + self.bal_window.willexecutors, ping_on_success, ping_on_failure + ) + + self.bal_window.download_list(self.bal_window.willexecutors, on_success) + + elif index == 3: + # TODO DO NOTHING + pass + + self.bal_window.will_list_widget.update() + if self.validate(): + return self.on_next() + + def import_json_file(self, path): + data = read_json_file(path) + data = self._validate(data) + self.willexecutors = data + + def _validate(self, data): + return data + + + +class BalWizardWEWidget(BalWizardWidget): + title = "Bitcoin After Life Will-Executors" + message = _("Configure and select your willexecutors") + + def get_content(self): + # Lazy import to avoid a dialogs<->lists import cycle. + from .lists import WillExecutorWidget + widget = QWidget() + vbox = QVBoxLayout(widget) + vbox.addWidget( + WillExecutorWidget( + self, + self.bal_window, + Willexecutors.get_willexecutors(self.bal_window.bal_plugin), + ) + ) + return widget + + + +class BalWizardLocktimeAndFeeWidget(BalWizardWidget): + title = "Bitcoin After Life Will Settings" + message = _("") + + def get_content(self): + widget = QWidget() + layout = QVBoxLayout(widget) + + # The wizard ("Build your will") is the ONLY place the delivery time, + # check alive and fee can be edited, so it is the only read_only=False. + layout.addWidget(WillSettingsWidget(self.bal_window, self, "v", + read_only=False)) + spacer_widget = QWidget() + spacer_widget.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + layout.addWidget(spacer_widget) + return widget + + + +class BalWaitingDialog(BalDialog): + updatemessage = pyqtSignal([str], arguments=["message"]) + + def __init__( + self, + bal_window: "BalWindow", + message: str, + task, + on_success=None, + on_error=None, + on_cancel=None, + exe=True, + ): + assert bal_window + BalDialog.__init__( + self, bal_window.window, bal_window.bal_plugin, _("Please wait") + ) + self.message_label = QLabel(message) + vbox = QVBoxLayout(self) + vbox.addWidget(self.message_label) + self.updatemessage.connect(self.update_message) + if on_cancel: + self.cancel_button = CancelButton(self) + self.cancel_button.clicked.connect(on_cancel) + vbox.addLayout(Buttons(self.cancel_button)) + self.accepted.connect(self.on_accepted) + self.task = task + self.on_success = on_success + self.on_error = on_error + self.on_cancel = on_cancel + if exe: + self.exe() + + def exe(self): + self.thread = TaskThread(self) + self.thread.finished.connect(self.deleteLater) # see #3956 + self.thread.finished.connect(self.finished) + self.thread.add(self.task, self.on_success, self.accept, self.on_error) + # IMPORTANT: keep the *application-modal* exec() of the original code. + # This dialog is driven by a TaskThread whose result (on_success, e.g. + # populating the will-executor list) is delivered via a queued signal + # while exec() spins the modal event loop. Switching to window-modal + # changed how the modal loop interacts with that delivery and could + # cause the downloaded list to never be applied. We only add the + # raise/activate so the dialog stays visible, without altering modality. + bring_to_front(self) + self.exec() + + def hello(self): + pass + + def finished(self): + pass + + + def on_accepted(self): + pass + + def update_message(self, msg): + self.message_label.setText(msg) + + def update(self, msg): + self.updatemessage.emit(msg) + + def getText(self): + return self.message_label.text() + + + + +class BalBlockingWaitingDialog(BalDialog): + def __init__(self, bal_window: "BalWindow", message: str, task: Callable[[], Any]): + BalDialog.__init__(self, bal_window, bal_window.bal_plugin, _("Please wait")) + self.message_label = QLabel(message) + vbox = QVBoxLayout(self) + vbox.addWidget(self.message_label) + self.finished.connect(self.deleteLater) # see #3956 + # show popup (window-modal + on top so it is actually visible) + show_on_top(self) + # Refresh the GUI so the popup is painted (and message_label drawn) + # BEFORE we block the GUI thread running the task; otherwise the popup + # appears empty/frozen. + from PyQt6.QtWidgets import QApplication + QApplication.processEvents() + QApplication.processEvents() + try: + # block and run given task + task() + finally: + # close popup + self.accept() + + +class BalBuildWillDialog(BalDialog): + updatemessage = pyqtSignal() + COLOR_WARNING = "#cfa808" + COLOR_ERROR = "#ff0000" + COLOR_OK = "#05ad05" + + def __init__(self, bal_window, parent=None): + if not parent: + parent = bal_window.window + BalDialog.__init__(self, parent, bal_window.bal_plugin, _("Building Will")) + # (parent already stored as self._bal_parent by BalDialog.__init__) + self.updatemessage.connect(self.msg_update) + self.bal_window = bal_window + self.bal_plugin = bal_window.bal_plugin + self.message_label = QLabel(_("Building Will:")) + self.vbox = QVBoxLayout(self) + self.vbox.addWidget(self.message_label, 0) + self.qwidget = QWidget(self) + self.vbox.addWidget(self.qwidget, 1) + self.labelsbox = QVBoxLayout(self.qwidget) + self.setMinimumWidth(600) + self.setMinimumHeight(100) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.labels = [] + self.check_row = None + self.inval_row = None + self.build_row = None + self.sign_row = None + self.push_row = None + # Manual next-steps hint (Sign / Broadcast) shown to the user after the + # dialog finishes; None when nothing is left to do. + self._next_steps_hint = None + self.network = Network.get_instance() + self._stopping = False + self.thread = TaskThread(self) + self.thread.finished.connect(self.task_finished) # see #3956 + + def task_finished(self): + pass + + def build_will_task(self): + _logger.debug("build will task to be started") + self.thread.add( + self.task_phase1, + on_success=self.on_success_phase1, + on_done=self.on_accept, + on_error=self.on_error_phase1, + ) + # exec() already shows the dialog modally; route through the helper so + # it is window-modal and brought to the front (no separate show()). + show_modal(self) + + def task_phase1(self): + if self._stopping: + return + txs = None + _logger.debug("close plugin phase 1 started") + varrow = self.msg_set_status("Checking variables") + try: + self.bal_window.init_class_variables() + except CheckAliveError as cae: + fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) + tx = Will.invalidate_will( + self.bal_window.willitems, self.bal_window.wallet, fee_per_byte + ) + if tx: + _logger.debug( + "during phase1 CAE: {}, Continue to invalidate".format(cae) + ) + self.msg_set_status("Checking variables",varrow, "Check Alive Threshold Passed: you have to Invalidate your old Will",self.COLOR_ERROR) + else: + raise cae + return None, tx + except NoHeirsException: + self.msg_set_status("Checking variables", varrow,"No Heirs",self.COLOR_ERROR) + #self.msg_set_checking("No Heirs") + return False, None + except Exception as e: + raise e + try: + _logger.debug("checking variables") + Will.check_amounts( + self.bal_window.heirs, + self.bal_window.willexecutors, + self.bal_window.window.wallet.get_utxos(), + self.bal_window.date_to_check, + self.bal_window.window.wallet.dust_threshold(), + ) + _logger.debug("variables ok") + self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK) + except AmountException: + self.msg_set_checking( + self.msg_warning( + "In the inheritance process, " + + "the entire wallet will always be fully emptied. \n" + + "Your settings require an adjustment of the amounts" + ) + ) + + self.msg_set_checking() + have_to_build = False + try: + self.bal_window.check_will() + self.msg_set_checking(self.msg_ok()) + except WillExpiredException: + _logger.debug("expired") + self.msg_set_checking("Expired") + fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) + return None, Will.invalidate_will( + self.bal_window.willitems, self.bal_window.wallet, fee_per_byte + ) + except WillPostponedException as e: + # An already signed/sent will is being postponed. Like an expired + # will, the previously committed coins must be invalidated on-chain + # FIRST (otherwise a will-executor could broadcast the old, + # earlier-locktime tx and execute the inheritance too early). We + # return (None, tx) so phase 2 asks the user to sign and broadcast + # the invalidation; afterwards the user presses Prepare again to + # rebuild the new (postponed) inheritance. + _logger.debug(f"postponed {e}") + self.msg_set_checking(_("Postponed: invalidating old will")) + fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) + return None, Will.invalidate_will( + self.bal_window.willitems, self.bal_window.wallet, fee_per_byte + ) + except NoHeirsException as e: + _logger.debug("no heirs") + self.msg_set_checking("No Heirs") + except NotCompleteWillException as e: + _logger.debug(f"not complete {e} true") + message = False + have_to_build = True + if isinstance(e, HeirChangeException): + message = _("Heirs changed:") + elif isinstance(e, WillExecutorNotPresent): + message = _("Will-Executor not present") + elif isinstance(e, WillexecutorChangeException): + message = _("Will-Executor changed") + elif isinstance(e, TxFeesChangedException): + message = _("Txfees are changed") + elif isinstance(e, HeirNotFoundException): + message = _("Heir not found") + if message: + _logger.debug(f"message: {message}") + self.msg_set_checking(message) + else: + self.msg_set_checking("New") + + if have_to_build: + self.msg_set_building() + try: + txs = self.bal_window.build_will() + if not txs: + self.msg_set_building( + _("Balance is too low, or CheckAlive is in the past.Skipped"), + color = self.COLOR_ERROR, + ) + return False, None + + self.bal_window.check_will() + for wid in Will.only_valid(self.bal_window.willitems): + self.bal_window.wallet.set_label(wid, "BAL Transaction") + self.msg_set_building(self.msg_ok()) + except WillExecutorNotPresent: + self.msg_set_status( + _("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR + ) + + except Exception as e: + _logger.exception("build_will failed") + self.msg_set_building(self.msg_error(e)) + return False, None + + # excluded_heirs = [] + for wid in Will.only_valid(self.bal_window.willitems): + heirs = self.bal_window.willitems[wid].heirs + for hid, heir in heirs.items(): + if "DUST" in str(heir[HEIR_REAL_AMOUNT]): + self.msg_set_status( + f"{hid},{heir[HEIR_DUST_AMOUNT]} is DUST", + None, + f"Excluded from will {wid}", + self.COLOR_WARNING, + ) + + have_to_sign = False + for wid in Will.only_valid(self.bal_window.willitems): + if not self.bal_window.willitems[wid].get_status("COMPLETE"): + have_to_sign = True + break + return have_to_sign, txs + + def on_accept(self): + self.bal_window.update_all() + pass + + def on_accept_phase2(self): + self.bal_window.update_all() + pass + + def on_error_push(self): + pass + + def wait(self, secs): + wait_row = None + for i in range(secs, 0, -1): + if self._stopping: + return + wait_row = self.msg_edit_row(_(f"Please wait {i}secs"), wait_row) + time.sleep(1) + self.msg_del_row(wait_row) + + def loop_broadcast_invalidating(self, tx): + if self._stopping: + return + self.msg_set_invalidating("Broadcasting") + try: + tx.add_info_from_wallet(self.bal_window.wallet) + self.network.run_from_another_thread(tx.add_info_from_network(self.network)) + txid = self.network.run_from_another_thread( + self.network.broadcast_transaction(tx, timeout=120), timeout=120 + ) + self.msg_set_invalidating(self.msg_ok()) + if not txid: + _logger.debug(f"should not be none txid: {txid}") + + except TxBroadcastError as e: + _logger.error(f"fail to broadcast transaction:{e}") + msg = e.get_message_for_gui() + self.msg_set_invalidating(self.msg_error(msg)) + except BestEffortRequestFailed as e: + self.msg_set_invalidating(self.msg_error(e)) + + def loop_push(self): + if self._stopping: + return + self.msg_set_pushing(_("Broadcasting")) + retry = False + try: + + willexecutors = Willexecutors.get_willexecutor_transactions( + self.bal_window.willitems + ) + + # Only push to the will-executors the user actually selected. We + # filter the mapping up-front so push_transactions_parallel only + # talks to the relevant servers. + selected = { + url: we + for url, we in willexecutors.items() + if Willexecutors.is_selected(self.bal_window.willexecutors.get(url)) + } + + # Servers that report "already present" need their stored tx + # verified afterwards (network I/O); collect them here and process + # them sequentially after the parallel push, keeping the original + # check logic untouched. + already_present = [] + retry_flag = {"value": False} + total = len(selected) + done = {"count": 0} + + deadline = Willexecutors.PUSH_GLOBAL_DEADLINE + + def _status_line(): + # e.g. "Broadcasting your will to executors: 2/3 (5s / 30s)". + # The "/ 30s" makes the maximum wait explicit, so the user knows + # the wizard will proceed by then (the global deadline) instead + # of wondering how long the counter will keep climbing. + return "{} {}/{} ({}s / {}s)".format( + _("Broadcasting"), done["count"], total, + min(int(time.time() - push_start), deadline), deadline, + ) + + def on_each(url, willexecutor, ok, exc): + # Runs from a worker thread. Do only thread-safe book-keeping + # plus a signal-based UI update (msg_edit_row emits a pyqtSignal, + # which is marshalled to the GUI thread). + if isinstance(exc, Willexecutors.AlreadyPresentException): + already_present.append(url) + elif ok: + for wid in willexecutor["txsids"]: + self.bal_window.willitems[wid].set_status("PUSHED", True) + else: + for wid in willexecutor["txsids"]: + self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) + retry_flag["value"] = True + done["count"] += 1 + # Show the per-server result (Ok/Ko) in bold + color so the + # outcome stands out, keeping the server URL in normal weight. + result = self.msg_ok("Ok") if ok else self.msg_error("Ko") + self.msg_edit_row("{} : {}".format(url, result)) + self.msg_set_pushing(_status_line()) + + def on_timeout(url, willexecutor): + # The global deadline elapsed before this server answered. Mark + # its txs as failed (so the user can retry later) and show it. + for wid in willexecutor.get("txsids", []): + self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) + retry_flag["value"] = True + self.msg_edit_row( + "{} : {}".format(url, self.msg_error(_("Timeout - no answer"))) + ) + + if self._stopping: + return + # Push to all selected will-executors in parallel: a slow/dead + # server no longer blocks the others, so the wizard's "Broadcasting" + # step is no longer sequential. Each server keeps a short retry + # behaviour, and a global deadline guarantees the wizard always + # proceeds even if a server never answers. + push_start = time.time() + self.msg_set_pushing(_status_line()) + + # Refresh the elapsed-seconds counter while the (blocking) parallel + # push runs, so the user sees time advancing instead of a frozen + # "Trasmissione". The tick is driven from THIS (Task) thread by + # push_transactions_parallel, the same thread that drives on_each, so + # the pyqtSignal repaint is reliable (a separate heartbeat thread's + # signal emissions were not being marshalled and never repainted). + def on_tick(): + if self._stopping: + return + self.msg_set_pushing(_status_line()) + + Willexecutors.push_transactions_parallel( + selected, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick + ) + + # Final summary line with the total elapsed time. + self.msg_set_pushing( + "{}/{} ({}s)".format(done["count"], total, + int(time.time() - push_start)) + ) + retry = retry_flag["value"] + + # Verify the "already present" servers (sequential, original logic). + self.bal_plugin = self.bal_window.bal_plugin + for url in already_present: + for wid in willexecutors[url]["txsids"]: + if self._stopping: + return + row = self.msg_edit_row( + "checking {} - {} : {}".format( + self.bal_window.willitems[wid].we["url"], wid, "Waiting" + ) + ) + w = self.bal_window.willitems[wid] + w.set_check_willexecutor( + Willexecutors.check_transaction(wid, w.we["url"]) + ) + # Show the CHECKED result in bold + color (green True / + # red False) so the outcome stands out, keeping the server + # URL and tx id in normal weight. + checked = self.bal_window.willitems[wid].get_status("CHECKED") + result = self.msg_ok(checked) if checked else self.msg_error(checked) + row = self.msg_edit_row( + "checked {} - {} : {}".format( + self.bal_window.willitems[wid].we["url"], + wid, + result, + ), + row, + ) + + if retry: + raise Exception("retry") + + except Exception as e: + self.msg_set_pushing(self.msg_error(e)) + self.wait(10) + if not self._stopping: + pass + # self.loop_push() + + def invalidate_task(self, password, bal_window, tx): + if self._stopping: + return + _logger.debug(f"invalidate tx: {tx}") + # fee_per_byte = bal_window.will_settings.get("baltx_fees", 1) + tx = self.bal_window.wallet.sign_transaction(tx, password) + try: + if tx: + if tx.is_complete(): + self.loop_broadcast_invalidating(tx) + self.wait(5) + else: + raise Exception("tx not complete") + else: + raise Exception("not tx") + except Exception as e: + (f"exception:{e}") + self.msg_set_invalidating(f"Error: {e}") + raise Exception("Impossible to sign") from e + + def on_success_invalidate(self, success): + self.thread.add( + self.task_phase1, + on_success=self.on_success_phase1, + on_done=self.on_accept, + on_error=self.on_error_phase1, + ) + + def on_success_phase1(self, result): + if self._stopping: + return + self.have_to_sign, tx = list(result) + _logger.debug("have to sign {}".format(self.have_to_sign)) + password = None + if self.have_to_sign is None: + _logger.debug("have to invalidate") + self.msg_set_invalidating() + + password = self.bal_window.get_wallet_password( + _("Invalidate your old will"), parent=self + ) + if password is False: + self.msg_set_invalidating(_("Aborted")) + self.wait(3) + self.close() + return + self.thread.add( + partial(self.invalidate_task, password, self.bal_window, tx), + on_success=self.on_success_invalidate, + on_done=self.on_accept, + on_error=self.on_error, + ) + return + + elif self.have_to_sign: + auto_sign = self.bal_plugin.AUTO_SIGN.get() + if auto_sign: + self.msg_set_signing(_("Auto-signing...")) + else: + password = self.bal_window.get_wallet_password( + _("Sign your will"), parent=self + ) + if password is False: + self.msg_set_signing(_("Password cancelled")) + self._show_next_steps_hint() + self._add_close_button() + return + else: + self.msg_set_signing(_("Nothing to do")) + self.thread.add( + partial(self.task_phase2, password), + on_success=self.on_success_phase2, + on_done=self.on_accept_phase2, + on_error=self.on_error_phase2, + ) + return + + def on_success_phase2(self, arg=False): + self.thread.stop() + self.bal_window.save_willitems() + self.msg_edit_row(_("Finished")) + # Instead of auto-closing after a countdown, let the user decide when to + # dismiss the dialog: they can read the full "Building Will" report at + # their own pace and then press "Close". This runs in the GUI thread + # (on_success callback) so building the button here is safe. + self._add_close_button() + + def _add_close_button(self): + """Add a right-aligned "Close" button to dismiss the dialog manually. + + Replaces the old automatic countdown (self.wait(5) + self.close()). + Guarded so it is only built once even if called again. + """ + if getattr(self, "_close_button", None) is not None: + return + self._close_button = QPushButton(_("Close")) + self._close_button.clicked.connect(self._on_close_clicked) + button_row = QHBoxLayout() + button_row.addStretch(1) + button_row.addWidget(self._close_button) + self.vbox.addLayout(button_row) + self._close_button.setFocus() + + def _on_close_clicked(self): + # Close the dialog first, then show the persistent popup guiding the + # user through any remaining MANUAL steps (Sign / Broadcast). Showing + # the (modal) hint after close() mirrors the previous behaviour where + # the hint appeared once the auto-closing dialog was gone. + self.close() + if self._next_steps_hint: + self.bal_window.show_message(self._next_steps_hint) + + def closeEvent(self, event): + self._stopping = True + # Stop AND join the thread, then propagate the close event (previously + # it neither waited nor called super().closeEvent()). + stop_thread(getattr(self, "thread", None)) + super().closeEvent(event) + + def task_phase2(self, password): + if self._stopping: + return + if self.have_to_sign: + try: + if txs := self.bal_window.sign_transactions(password): + for txid, tx in txs.items(): + self.bal_window.willitems[txid].tx = copy.deepcopy(tx) + self.bal_window.save_willitems() + self.msg_set_signing(self.msg_ok()) + except Exception as e: + self.msg_set_signing(self.msg_error(e)) + + self.msg_set_pushing() + have_to_push = False + for wid in Will.only_valid(self.bal_window.willitems): + w = self.bal_window.willitems[wid] + if w.we and w.get_status("COMPLETE") and not w.get_status("PUSHED"): + have_to_push = True + if not have_to_push: + self.msg_set_pushing(_("Nothing to do")) + else: + try: + self.loop_push() + self.msg_set_pushing(self.msg_ok()) + + except Exception as e: + # td = traceback.format_exc() + self.msg_set_pushing(self.msg_error(e)) + # Blank separator row: visually detach the final "All done" summary + # from the per-step result rows above it, so the closing line stands + # out as the overall outcome rather than just another step. + self.msg_edit_row("") + # Final summary row: the whole "Building Will" sequence above (check / + # sign / broadcast) finished without errors. Give it an explicit + # left-side label ("All done") so this closing Ok is not an orphan + # result like the other rows have. + self.msg_edit_row("{}:\t{}".format(_("All done"), self.msg_ok())) + + # Guide the user through any remaining MANUAL steps. After the will is + # (re)built -- e.g. because an heir was removed/added from the Wizard -- + # the new transactions may still need to be SIGNED and/or BROADCAST by + # the user. This dialog only signs/pushes automatically when it already + # has the password and the will is in the right state; in every other + # case the user is otherwise left without any indication of what to do + # next. We inspect the real status of the valid wills and tell the user + # exactly which buttons to press. + self._show_next_steps_hint() + + def _show_next_steps_hint(self): + """Append a clear "what to do next" line to the Building Will dialog. + + Pure UX guidance (no logic change): looks at the valid wills and, if any + still needs signing or broadcasting, tells the user to press 'Sign' + and/or 'Broadcast' manually. The computed hint is also stored in + ``self._next_steps_hint`` so a persistent popup can be shown after the + dialog closes (this dialog auto-closes after a few seconds, which is too + short to be sure the user noticed the in-dialog line). + """ + self._next_steps_hint = None + try: + need_sign = False + need_push = False + for wid in Will.only_valid(self.bal_window.willitems): + w = self.bal_window.willitems[wid] + if not w.get_status("COMPLETE"): + # Not signed yet. + need_sign = True + elif w.we and not w.get_status("PUSHED"): + # Signed but not yet sent to its will-executor. + need_push = True + + if need_sign and need_push: + hint = _( + "Next steps (manual): press 'Sign' to sign your will, " + "then 'Broadcast' to send it to the will-executors." + ) + elif need_sign: + hint = _( + "Next step (manual): press 'Sign' to sign your will." + ) + elif need_push: + hint = _( + "Next step (manual): press 'Broadcast' to send your will " + "to the will-executors." + ) + else: + # Nothing left to do (already signed and, if needed, sent). + return + + self._next_steps_hint = hint + self.msg_edit_row("{}".format(hint)) + except Exception as hint_err: + _logger.debug(f"next-steps hint error: {hint_err}") + + def on_error(self, error): + _logger.error(error) + pass + + def on_error_phase1(self, error): + self.bal_window.update_all() + a, b, c = error + self.msg_edit_row(self.msg_error(f"Error: {b}")) + _logger.error(f"error phase1: {b}") + button=QPushButton(_("Close")) + button.clicked.connect(self.close) + self.vbox.addWidget(button) + self.resize(self.vbox.sizeHint()+button.sizeHint()*2) + self.repaint() + def on_error_phase2(self, error): + self.bal_window.upade_all() + a, b, c = error + self.msg_edit_row(self.msg_error(f"Error: {b}")) + _logger.error(f"error phase2: {b}") + + def msg_set_checking(self, status="Waiting", row=None): + row = self.check_row if row is None else row + self.check_row = self.msg_set_status(_("Checking your will"), row, status) + + def msg_set_invalidating(self, status=None, row=None): + row = self.inval_row if row is None else row + self.inval_row = self.msg_set_status( + _("Invalidating old will"), self.inval_row, status + ) + + def msg_set_building(self, status=None, row=None,color=None): + row = self.build_row if row is None else row + self.build_row = self.msg_set_status( + "Building your will", self.build_row, status, color + ) + + def msg_set_signing(self, status=None, row=None): + row = self.sign_row if row is None else row + self.sign_row = self.msg_set_status("Signing your will", self.sign_row, status) + + def msg_set_pushing(self, status=None, row=None): + row = self.push_row if row is None else row + self.push_row = self.msg_set_status( + "Broadcasting your will to executors", self.push_row, status + ) + + def msg_set_waiting(self, status=None, row=None): + row = self.wait_row if row is None else row + self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row) + + def msg_error(self, e): + # Results are shown in bold so the outcome stands out from the + # left-side state label (which stays in normal weight). + return "{}".format(self.COLOR_ERROR, e) + + def msg_ok(self, e="Ok"): + # Results are shown in bold (see msg_error). + return "{}".format(self.COLOR_OK, e) + + def msg_warning(self, e): + # Results are shown in bold (see msg_error). + return "{}".format(self.COLOR_WARNING, e) + + def msg_set_status(self, msg, row=None, status=None, color=None): + # The left "state" label keeps its normal weight; only the right-side + # result (``status``) is rendered in bold so it is easy to read at a + # glance. ``status`` may already contain rich-text emitted by + # msg_ok/msg_error/msg_warning (which add their own ...); wrapping + # it again in is harmless for those cases. + status = "Wait" if status is None else status + if color is None: + line = "{}:\t{}".format(_(msg), status) + else: + line = "{}:\t{}".format( + _(msg), color, status + ) + return self.msg_edit_row(line, row) + + def ask_password(self, msg=None): + self.password = self.bal_window.get_wallet_password(msg, parent=self) + + def msg_edit_row(self, line, row=None): + try: + self.labels[row] = line + except Exception: + self.labels.append(line) + row = len(self.labels) - 1 + + self.updatemessage.emit() + + return row + + def msg_del_row(self, row): + try: + del self.labels[row] + except Exception: + pass + self.updatemessage.emit() + + # def clear_layout(self,layout): + # while layout.count(): + # item = layout.takeAt(0) + # w = item.widget() + # if w: + # w.setParent(None) + # w.deleteLater() + + # def msg_update(self): + # self.clear_layout(self.labelsbox) + # for label in self.labels: + # label=label.replace("\n","
") + # qlabel=QLabel(label) + # qlabel.setWordWrap(True) + # self.labelsbox.addWidget(qlabel) + + # self.labelsbox.activate() + # self.qwidget.setMinimumSize(self.labelsbox.sizeHint()) + # self.qwidget.adjustSize() + # from PyQt6.QtWidgets import QApplication + # QApplication.processEvents() + # + # self.adjustSize() + def msg_update(self): + full_text = "

".join(self.labels).replace("\n", "
") + self.message_label.setText(full_text) + self.message_label.adjustSize() + # self.setMinimumHeight(len(self.labels)*40) + self.resize(self.sizeHint()) + + def get_text(self): + return self.message_label.text() + + pass + + + +class WillDetailDialog(BalDialog): + def __init__(self, bal_window): + + self.will = bal_window.willitems + self.threshold = bal_window.will_settings["real_threshold"] + + self.bal_window = bal_window + Will.add_willtree(self.will) + super().__init__(bal_window.window, bal_window.bal_plugin) + self.config = bal_window.window.config + self.wallet = bal_window.wallet + self.format_amount = bal_window.window.format_amount + self.base_unit = bal_window.window.base_unit + self.format_fiat_and_units = bal_window.window.format_fiat_and_units + self.fx = bal_window.window.fx + self.format_fee_rate = bal_window.window.format_fee_rate + self.decimal_point = bal_window.window.get_decimal_point() + self.base_unit_name = decimal_point_to_base_unit_name(self.decimal_point) + self.setWindowTitle(_("Will Details")) + self.setMinimumSize(670, 700) + self.vlayout = QVBoxLayout() + w = QWidget() + hlayout = QHBoxLayout(w) + + b = QPushButton(_("Sign")) + b.clicked.connect(self.ask_password_and_sign_transactions) + hlayout.addWidget(b) + + b = QPushButton(_("Broadcast")) + b.clicked.connect(self.broadcast_transactions) + hlayout.addWidget(b) + + b = QPushButton(_("Export")) + b.clicked.connect(self.export_will) + hlayout.addWidget(b) + b = QPushButton(_("Invalidate")) + b.clicked.connect(bal_window.invalidate_will) + hlayout.addWidget(b) + self.vlayout.addWidget(w) + + self.paint_scroll_area() + self.vlayout.addWidget( + QLabel(_("Expiration date: ") + str(BalTimestamp(self.threshold))) + ) + self.vlayout.addWidget(self.scrollbox) + w = QWidget() + hlayout = QHBoxLayout(w) + hlayout.addWidget( + QLabel(_("Valid Txs:") + str(len(Will.only_valid_list(self.will)))) + ) + hlayout.addWidget(QLabel(_("Total Txs:") + str(len(self.will)))) + self.vlayout.addWidget(w) + self.setLayout(self.vlayout) + + def paint_scroll_area(self): + self.scrollbox = QScrollArea() + viewport = QWidget(self.scrollbox) + self.willlayout = QVBoxLayout(viewport) + self.detailsWidget = WillWidget(parent=self) + self.willlayout.addWidget(self.detailsWidget) + + self.scrollbox.setWidget(viewport) + viewport.setLayout(self.willlayout) + + def ask_password_and_sign_transactions(self): + self.bal_window.ask_password_and_sign_transactions(callback=self.update) + self.update() + + def broadcast_transactions(self): + self.bal_window.broadcast_transactions() + self.update() + + def export_will(self): + self.bal_window.export_will() + + def toggle_replaced(self): + self.bal_window.bal_plugin.hide_replaced() + toggle = _("Hide") + if self.bal_window.bal_plugin._hide_replaced: + toggle = _("Unhide") + self.toggle_replace_button.setText(f"{toggle} {_('replaced')}") + self.update() + + def toggle_invalidated(self): + self.bal_window.bal_plugin.hide_invalidated() + toggle = _("Hide") + if self.bal_window.bal_plugin._hide_invalidated: + toggle = _("Unhide") + self.toggle_invalidate_button.setText(_(f"{toggle} {_('invalidated')}")) + self.update() + + def update(self): + self.will = self.bal_window.willitems + pos = self.vlayout.indexOf(self.scrollbox) + self.vlayout.removeWidget(self.scrollbox) + self.paint_scroll_area() + self.vlayout.insertWidget(pos, self.scrollbox) + super().update() + + + +class WillExecutorDialog(BalDialog, MessageBoxMixin): + def __init__(self, bal_window, parent=None): + if not parent: + parent = bal_window.window + BalDialog.__init__(self, parent, bal_window.bal_plugin) + self.bal_plugin = bal_window.bal_plugin + self.config = self.bal_plugin.config + self.bal_window = bal_window + self.willexecutors_list = Willexecutors.get_willexecutors(self.bal_plugin) + + self.setWindowTitle(_("Will-Executor Service List")) + self.setMinimumSize(1000, 200) + + # Lazy import to avoid a dialogs<->lists import cycle. + from .lists import WillExecutorWidget + vbox = QVBoxLayout(self) + self.will_executor_list_widget = WillExecutorWidget( + self, self.bal_window, self.willexecutors_list + ) + vbox.addWidget(self.will_executor_list_widget) + + def is_hidden(self): + return self.isMinimized() or self.isHidden() + + def show_or_hide(self): + if self.is_hidden(): + self.bring_to_top() + else: + self.hide() + + def bring_to_top(self): + self.show() + # raise_() alone does not grab focus on some window managers (Windows); + # activateWindow() ensures the dialog actually comes to the front. + bring_to_front(self) + + def closeEvent(self, event): + event.accept() + + diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py new file mode 100644 index 0000000..b853191 --- /dev/null +++ b/bal/gui/qt/lists.py @@ -0,0 +1,995 @@ +""" +bal.gui.qt.lists +================ + +Tree/list views (subclasses of Electrum's ``MyTreeView``) and their toolbars. + + * HeirListWidget - editable list of heirs (address / amount / locktime). + * PreviewList - preview of the will transactions before signing. + * WillExecutorListWidget- list of will-executor servers. + * WillExecutorWidget - container combining the list with add/import buttons. + +These views call back into the :class:`BalWindow` controller (passed at +construction) for all business actions, so the heavy logic stays in ``window`` +and ``dialogs``. +""" + +from .common import * +from .common import _, _logger # underscore names are not re-exported by "import *" +from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget +from .dialogs import BalBuildWillDialog + + +class HeirListWidget(MyTreeView, MessageBoxMixin): + class Columns(MyTreeView.BaseColumnsEnum): + NAME = enum.auto() + ADDRESS = enum.auto() + AMOUNT = enum.auto() + + headers = { + Columns.NAME: _("Name"), + Columns.ADDRESS: _("Address"), + Columns.AMOUNT: _("Amount"), + } + filter_columns = [Columns.NAME, Columns.ADDRESS] + + ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000 + + ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 4000 + key_role = ROLE_HEIR_KEY + + def createEditor(self, parent, option, index): + return QLineEdit(parent) + + def setEditorData(self, editor, index): + editor.setText(index.data()) + + def setModelData(self, editor, model, index): + model.setData(index, editor.text()) + + def __init__(self, bal_window: "BalWindow", parent): + super().__init__( + parent=parent, + main_window=bal_window.window, + stretch_column=self.Columns.NAME, + editable_columns=[ + self.Columns.NAME, + self.Columns.ADDRESS, + self.Columns.AMOUNT, + ], + ) + self.decimal_point = bal_window.window.get_decimal_point() + self.bal_window = bal_window + + try: + self.setModel(QStandardItemModel(self)) + self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + except Exception: + pass + + self.setSortingEnabled(True) + self.std_model = self.model() + + self.update() + + def on_activated(self, idx): + self.on_double_click(idx) + + def on_double_click(self, idx): + edit_key = self.get_edit_key_from_coordinate(idx.row(), idx.column()) + self.bal_window.heirs.get(edit_key) + self.bal_window.new_heir_dialog(edit_key) + + def on_edited(self, idx, edit_key, *, text): + original = prior_name = self.bal_window.heirs.get(edit_key) + if not prior_name: + return + col = idx.column() + try: + if col == 2: + text = Util.encode_amount(text, self.decimal_point) + elif col == 0: + self.bal_window.delete_heirs([edit_key]) + edit_key = text + prior_name[col - 1] = text + prior_name.insert(0, edit_key) + prior_name = tuple(prior_name) + except Exception: + prior_name = ( + (edit_key,) + prior_name[: col - 1] + (text,) + prior_name[col:] + ) + + try: + self.bal_window.set_heir(prior_name) + except Exception: + pass + + try: + self.bal_window.set_heir((edit_key,) + original) + except Exception: + self.update() + + def delete_heirs(self, selected_keys): + self.bal_window.delete_heirs(selected_keys) + self.update() + + def create_menu(self, position): + menu = QMenu() + idx = self.indexAt(position) + column = idx.column() or self.Columns.NAME + selected_keys = [] + for s_idx in self.selected_in_column(self.Columns.NAME): + sel_key = self.model().itemFromIndex(s_idx).data(0) + selected_keys.append(sel_key) + if selected_keys and idx.isValid(): + column_title = self.model().horizontalHeaderItem(column).text() + # ok + column_data = "\n".join( + self.model().itemFromIndex(s_idx).text() + for s_idx in self.selected_in_column(column) + ) + menu.addAction( + _("Copy {}").format(column_title), + lambda: self.place_text_on_clipboard(column_data, title=column_title), + ) + if column in self.editable_columns: + item = self.model().itemFromIndex(idx) + if item.isEditable(): + persistent = QPersistentModelIndex(idx) + menu.addAction( + _("Edit {}").format(column_title), + lambda p=persistent: self.edit(QModelIndex(p)), + ) + menu.addAction(_("Delete"), lambda: self.delete_heirs(selected_keys)) + menu.exec(self.viewport().mapToGlobal(position)) + + def update(self): + current_key = self.get_role_data_for_current_item( + col=self.Columns.NAME, role=self.ROLE_HEIR_KEY + ) + self.model().clear() + self.update_headers(self.__class__.headers) + set_current = None + for key in sorted(self.bal_window.heirs.keys()): + heir = self.bal_window.heirs[key] + labels = [""] * len(self.Columns) + labels[self.Columns.NAME] = key + labels[self.Columns.ADDRESS] = heir[0] + labels[self.Columns.AMOUNT] = Util.decode_amount( + heir[1], self.decimal_point + ) + + items = [QStandardItem(x) for x in labels] + items[self.Columns.NAME].setEditable(True) + items[self.Columns.ADDRESS].setEditable(True) + items[self.Columns.AMOUNT].setEditable(True) + items[self.Columns.NAME].setData( + key, self.ROLE_HEIR_KEY + self.Columns.NAME + ) + items[self.Columns.ADDRESS].setData( + key, self.ROLE_HEIR_KEY + self.Columns.ADDRESS + ) + items[self.Columns.AMOUNT].setData( + key, self.ROLE_HEIR_KEY + self.Columns.AMOUNT + ) + + row_count = self.model().rowCount() + self.model().insertRow(row_count, items) + + if key == current_key: + idx = self.model().index(row_count, self.Columns.NAME) + set_current = QPersistentModelIndex(idx) + try: + self.will_settings_widget.on_locktime_change() + except Exception as e: + pass + self.set_current_idx(set_current) + # FIXME refresh loses sort order; so set "default" here: + self.filter() + + def refresh_row(self, key, row): + # nothing to update here + pass + + def get_edit_key_from_coordinate(self, row, col): + a = self.get_role_data_from_coordinate(row, col, role=self.ROLE_HEIR_KEY + col) + return a + + def create_toolbar(self, config): + toolbar, menu = self.create_toolbar_with_menu("") + menu.addAction(_("&New Heir"), self.bal_window.new_heir_dialog) + menu.addAction(_("Import"), self.bal_window.import_heirs) + menu.addAction(_("Export"), lambda: self.bal_window.export_heirs()) + + newHeirButton = QPushButton(_("New Heir")) + newHeirButton.clicked.connect(self.bal_window.new_heir_dialog) + + widget = QWidget(self) + layout = QHBoxLayout(widget) + self.will_settings_widget = WillSettingsWidget(self.bal_window, self) + + layout.addWidget(self.will_settings_widget) + layout.addWidget(newHeirButton) + + toolbar.insertWidget(2, widget) + + return toolbar + + def build_transactions(self): + # will = self.bal_window.prepare_will() + self.bal_window.prepare_will() + + + +class PreviewList(MyTreeView, MessageBoxMixin): + class Columns(MyTreeView.BaseColumnsEnum): + LOCKTIME = enum.auto() + TXID = enum.auto() + WILLEXECUTOR = enum.auto() + STATUS = enum.auto() + SERVER = enum.auto() + + headers = { + Columns.LOCKTIME: _("Locktime"), + Columns.TXID: _("Txid"), + Columns.WILLEXECUTOR: _("Will-Executor"), + Columns.STATUS: _("Status"), + Columns.SERVER: _("Server"), + } + + ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000 + key_role = ROLE_HEIR_KEY + + def createEditor(self, parent, option, index): + return QLineEdit(parent) + + def setEditorData(self, editor, index): + editor.setText(index.data()) + + def setModelData(self, editor, model, index): + model.setData(index, editor.text()) + + def __init__(self, bal_window: "BalWindow", parent, will): + super().__init__( + parent=parent, + main_window=bal_window.window, + stretch_column=self.Columns.TXID, + ) + # self._bal_parent = parent + self.bal_window = bal_window + self.decimal_point = bal_window.window.get_decimal_point + + if will is not None: + self.will = will + else: + self.will = bal_window.willitems + + try: + self.setModel(QStandardItemModel(self)) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder) + except Exception as e: + pass + + self.setSortingEnabled(True) + self.std_model = self.model() + + self.update() + + def on_activated(self, idx): + self.on_double_click(idx) + + def on_double_click(self, idx): + idx = self.model().index(idx.row(), self.Columns.TXID) + sel_key = self.model().itemFromIndex(idx).data(0) + self.show_transaction([sel_key]) + + def create_menu(self, position): + menu = QMenu() + idx = self.indexAt(position) + column = idx.column() or self.Columns.TXID + selected_keys = [] + for s_idx in self.selected_in_column(self.Columns.TXID): + sel_key = self.model().itemFromIndex(s_idx).data(0) + selected_keys.append(sel_key) + if selected_keys and idx.isValid(): + column_title = self.model().horizontalHeaderItem(column).text() + # column_data = "\n".join( + # self.model().itemFromIndex(s_idx).text() + # for s_idx in self.selected_in_column(column) + # ) + + menu.addAction( + _("details").format(column_title), + lambda: self.show_transaction(selected_keys), + ).setEnabled(len(selected_keys) < 2) + menu.addAction( + _("check ").format(column_title), + lambda: self.check_transactions(selected_keys), + ) + if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get(): + try: + self.importaction = self.menu.addAction( + _("Import"), self.import_will + ) + except Exception: + pass + + menu.addSeparator() + menu.addAction( + _("delete").format(column_title), lambda: self.delete(selected_keys) + ) + + menu.exec(self.viewport().mapToGlobal(position)) + + def delete(self, selected_keys): + for key in selected_keys: + del self.will[key] + try: + del self.bal_window.willitems[key] + except Exception: + pass + try: + del self.bal_window.will[key] + except Exception: + pass + self.update() + + def check_transactions(self, selected_keys): + wout = {} + for k in selected_keys: + wout[k] = self.will[k] + if wout: + self.bal_window.check_transactions(wout) + self.update() + + def show_transaction(self, selected_keys): + for key in selected_keys: + self.bal_window.show_transaction(self.will[key].tx) + + self.update() + + def select(self, selected_keys): + self.selected += selected_keys + self.update() + + def deselect(self, selected_keys): + for key in selected_keys: + self.selected.remove(key) + self.update() + + def update_will(self, will): + self.will.update(will) + self.update() + + def replace(self, set_current, current_key, txid, bal_tx): + if self.bal_window.bal_plugin._hide_replaced and bal_tx.get_status("REPLACED"): + return False + if self.bal_window.bal_plugin._hide_invalidated and bal_tx.get_status( + "INVALIDATED" + ): + return False + + if not isinstance(bal_tx, WillItem): + bal_tx = WillItem(bal_tx) + + tx = bal_tx.tx + + labels = [""] * len(self.Columns) + labels[self.Columns.LOCKTIME] = str(BalTimestamp(tx.locktime)) + labels[self.Columns.TXID] = txid + we = "None" + if bal_tx.we: + we = bal_tx.we["url"] + labels[self.Columns.WILLEXECUTOR] = we + status = bal_tx.status + if len(bal_tx.status) > 53: + status = "...{}".format(status[-50:]) + labels[self.Columns.STATUS] = status + # Dedicated, always-readable label describing whether the inheritance + # transaction is actually stored on the will-executor servers. + labels[self.Columns.SERVER] = server_status_text(bal_tx) + + items = [] + for e in labels: + if isinstance(e, list): + try: + items.append(QStandardItem(*e)) + except Exception as e: + pass + else: + items.append(QStandardItem(str(e))) + + items[-1].setBackground(QColor(status_color(bal_tx))) + + # Tooltip on the Server column: shows the will-executor URL (if any) + # plus the current server state, so the user can always inspect details. + try: + items[self.Columns.SERVER].setToolTip(server_status_tooltip(bal_tx)) + except Exception as tip_err: + _logger.debug(f"server tooltip error: {tip_err}") + + row_count = self.model().rowCount() + self.model().insertRow(row_count, items) + if txid == current_key: + idx = self.model().index(row_count, self.Columns.TXID) + set_current = QPersistentModelIndex(idx) + self.set_current_idx(set_current) + return set_current + + def update(self): + try: + self.menu.removeAction(self.importaction) + except Exception: + pass + + if self.will is None: + return + + current_key = self.get_role_data_for_current_item( + col=self.Columns.TXID, role=self.ROLE_HEIR_KEY + ) + self.model().clear() + self.update_headers(self.__class__.headers) + + set_current = None + for txid, bal_tx in self.will.items(): + tmp = self.replace(set_current, current_key, txid, bal_tx) + if tmp: + set_current = tmp + self.sortByColumn(self.Columns.LOCKTIME, Qt.SortOrder.AscendingOrder) + self.setSortingEnabled(True) + try: + self.will_settings_widget.on_locktime_change() + except Exception as _e: + pass + + def create_toolbar(self, config): + toolbar, menu = self.create_toolbar_with_menu("") + menu.addAction(_("Prepare"), self.build_transactions) + menu.addAction(_("Display"), self.bal_window.preview_modal_dialog) + menu.addAction(_("Sign"), self.ask_password_and_sign_transactions) + menu.addAction(_("Export"), self.export_will) + if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get(): + self.importaction = menu.addAction(_("Import"), self.import_will) + menu.addAction(_("Broadcast"), self.broadcast) + menu.addAction(_("Check"), self.check) + menu.addAction(_("Invalidate"), self.invalidate_will) + + # The Wizard is the main entry point to create an inheritance, so make + # it stand out: show a bold label next to a slightly larger icon (the + # plain icon-only button was too easy to overlook). + wizard = QPushButton(" " + _("Create your will")) + wizard.setIcon( + read_QIcon_from_bytes( + self.bal_window.bal_plugin.read_file("icons/wizard.png") + ) + ) + wizard.setIconSize(QSize(28, 28)) + wizard.setMinimumHeight(40) + wizard.setStyleSheet("QPushButton{font-weight:bold;}") + # Tooltip so the button is self-explanatory when hovered. + wizard.setToolTip(_("Wizard - Build your will")) + wizard.clicked.connect(self.bal_window.init_wizard) + # display = QPushButton(_("Display")) + # display.clicked.connect(self.bal_window.preview_modal_dialog) + + refresh = QPushButton() + refresh.setIcon( + read_QIcon_from_bytes( + self.bal_window.bal_plugin.read_file("icons/reload.png") + ) + ) + # Tooltip so the icon is self-explanatory when hovered. + refresh.setToolTip(_("Check")) + refresh.clicked.connect(self.check) + + widget = QWidget(self) + hlayout = QHBoxLayout(widget) + hlayout.setContentsMargins(0, 0, 0, 0) + self.will_settings_widget = WillSettingsWidget(self.bal_window, self) + # Toolbar order (left -> right): + # Wizard | Delivery time | Check Alive | Calendar | Check (refresh) + # The Wizard button goes first (leftmost); the settings widget already + # lays out delivery/check-alive/calendar in that order internally. + hlayout.addWidget(wizard) + hlayout.addWidget(self.will_settings_widget) + hlayout.addWidget(refresh) + toolbar.insertWidget(2, widget) + + self.menu = menu + self.toolbar = toolbar + return toolbar + + def hide_replaced(self): + self.bal_window.bal_plugin.hide_replaced() + self.update() + + def hide_invalidated(self): + self.bal_window.bal_plugin.hide_invalidated() + self.update() + + def build_transactions(self): + will = self.bal_window.prepare_will() + if will: + self.update_will(will) + + def export_json_file(self, path): + write_json_file(path, self.will) + + def export_will(self): + self.bal_window.export_will() + self.update() + + def import_will(self): + self.bal_window.import_will() + + def ask_password_and_sign_transactions(self): + self.bal_window.ask_password_and_sign_transactions(callback=self.update) + + def broadcast(self): + self.bal_window.broadcast_transactions() + self.update() + + def check(self): + close_window = BalBuildWillDialog(self.bal_window) + close_window.build_will_task() + + will = {} + for wid, w in self.bal_window.willitems.items(): + # Query the will-executor server for every valid will that HAS a + # will-executor assigned and is not yet CHECKED. Previously only + # transactions already marked PUSHED were checked, so a will that + # had actually been sent in the past but whose saved status still + # read "New" (not PUSHED) was skipped and the Check button reported + # "nothing to do". Will.needs_server_check now also includes such + # non-PUSHED wills, so the server can confirm the transaction is + # present and correct the status (see set_check_willexecutor). + if Will.needs_server_check(w): + will[wid] = w + if will: + self.bal_window.check_transactions(will) + self.update() + + def invalidate_will(self): + self.bal_window.invalidate_will() + self.update() + + +# class PreviewDialog(BalDialog, MessageBoxMixin): +# def __init__(self, bal_window, will): +# self._bal_parent = bal_window.window +# BalDialog.__init__( +# self, bal_window=bal_window, bal_plugin=bal_window.bal_plugin +# ) +# self.bal_plugin = bal_window.bal_plugin +# self.gui_object = self.bal_plugin.gui_object +# self.config = self.bal_plugin.config +# self.bal_window = bal_window +# self.wallet = bal_window.window.wallet +# self.format_amount = bal_window.window.format_amount +# self.base_unit = bal_window.window.base_unit +# self.format_fiat_and_units = bal_window.window.format_fiat_and_units +# self.fx = bal_window.window.fx +# self.format_fee_rate = bal_window.window.format_fee_rate +# self.show_address = bal_window.window.show_address +# if not will: +# self.will = bal_window.willitems +# else: +# self.will = will +# self.setWindowTitle(_("Transactions Preview")) +# self.setMinimumSize(1000, 200) +# self.size_label = QLabel() +# self.transactions_list = PreviewList(self.bal_window,self, self.will) +# +# try: +# self.bal_window.init_class_variables() +# except Exception as e: +# _logger.error(f"PreviewDialog Exception: {e}") +# self.check_will() +# +# vbox = QVBoxLayout(self) +# vbox.addWidget(self.size_label) +# vbox.addWidget(self.transactions_list) +# buttonbox = QHBoxLayout() +# +# b = QPushButton(_("Sign")) +# b.clicked.connect(self.transactions_list.ask_password_and_sign_transactions) +# buttonbox.addWidget(b) +# +# b = QPushButton(_("Export Will")) +# b.clicked.connect(self.transactions_list.export_will) +# buttonbox.addWidget(b) +# +# b = QPushButton(_("Broadcast")) +# b.clicked.connect(self.transactions_list.broadcast) +# buttonbox.addWidget(b) +# +# b = QPushButton(_("Invalidate will")) +# b.clicked.connect(self.transactions_list.invalidate_will) +# buttonbox.addWidget(b) +# +# vbox.addLayout(buttonbox) +# +# self.update() +# +# def update_will(self, will): +# self.will.update(will) +# self.transactions_list.update_will(will) +# self.update() +# +# def update(self): +# self.transactions_list.update() +# +# def is_hidden(self): +# return self.isMinimized() or self.isHidden() +# +# def show_or_hide(self): +# if self.is_hidden(): +# self.bring_to_top() +# else: +# self.hide() +# +# def bring_to_top(self): +# self.show() +# self.raise_() +# +# def closeEvent(self, event): +# event.accept() + + + +class WillExecutorListWidget(MyTreeView): + class Columns(MyTreeView.BaseColumnsEnum): + SELECTED = enum.auto() + URL = enum.auto() + STATUS = enum.auto() + BASE_FEE = enum.auto() + INFO = enum.auto() + ADDRESS = enum.auto() + + headers = { + Columns.SELECTED: _(""), + Columns.URL: _("Url"), + Columns.STATUS: _("S"), + Columns.BASE_FEE: _("Base fee"), + Columns.INFO: _("Info"), + Columns.ADDRESS: _("Default Address"), + } + + filter_columns = [Columns.URL] + + ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 3000 + ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 3001 + key_role = ROLE_HEIR_KEY + + def __init__(self, parent: "WillExecutorWidget"): + super().__init__( + parent=parent, + stretch_column=self.Columns.ADDRESS, + editable_columns=[ + self.Columns.URL, + self.Columns.BASE_FEE, + self.Columns.ADDRESS, + self.Columns.INFO, + ], + ) + self._bal_parent = parent + try: + self.setModel(QStandardItemModel(self)) + self.sortByColumn(self.Columns.SELECTED, Qt.SortOrder.AscendingOrder) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + except Exception: + pass + self.setSortingEnabled(True) + self.std_model = self.model() + self.config = parent.bal_plugin.config + self.get_decimal_point = parent.bal_plugin.get_decimal_point + + self.update() + + def create_menu(self, position): + menu = QMenu() + idx = self.indexAt(position) + column = idx.column() or self.Columns.URL + selected_keys = [] + for s_idx in self.selected_in_column(self.Columns.URL): + sel_key = self.model().itemFromIndex(s_idx).data(0) + selected_keys.append(sel_key) + if selected_keys and idx.isValid(): + column_title = self.model().horizontalHeaderItem(column).text() + # column_data = "\n".join( + # self.model().itemFromIndex(s_idx).text() + # for s_idx in self.selected_in_column(column) + # ) + if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]): + menu.addAction( + _("deselect").format(column_title), + lambda: self.deselect(selected_keys), + ) + else: + menu.addAction( + _("select").format(column_title), lambda: self.select(selected_keys) + ) + if column in self.editable_columns: + item = self.model().itemFromIndex(idx) + if item.isEditable(): + persistent = QPersistentModelIndex(idx) + menu.addAction( + _("Edit {}").format(column_title), + lambda p=persistent: self.edit(QModelIndex(p)), + ) + + menu.addAction( + _("Ping").format(column_title), + lambda: self.ping_willexecutors(selected_keys), + ) + menu.addSeparator() + menu.addAction( + _("delete").format(column_title), lambda: self.delete(selected_keys) + ) + + menu.exec(self.viewport().mapToGlobal(position)) + + def ping_willexecutors(self, selected_keys): + wout = {} + for k in selected_keys: + wout[k] = self._bal_parent.willexecutors_list[k] + self._bal_parent.update_willexecutors(wout) + + self._bal_parent.save_willexecutors() + self.update() + + def get_edit_key_from_coordinate(self, row, col): + role = self.ROLE_HEIR_KEY + col + a = self.get_role_data_from_coordinate(row, col, role=role) + return a + + def delete(self, selected_keys): + for key in selected_keys: + del self._bal_parent.willexecutors_list[key] + + self._bal_parent.save_willexecutors() + self.update() + + def select(self, selected_keys): + for wid, w in self._bal_parent.willexecutors_list.items(): + if wid in selected_keys: + w["selected"] = True + self._bal_parent.save_willexecutors() + self.update() + + def deselect(self, selected_keys): + for wid, w in self._bal_parent.willexecutors_list.items(): + if wid in selected_keys: + w["selected"] = False + self._bal_parent.save_willexecutors() + self.update() + + def on_edited(self, idx, edit_key, *, text): + # prior_name = self._bal_parent.willexecutors_list[edit_key] + col = idx.column() + try: + if col == self.Columns.URL: + self._bal_parent.willexecutors_list[text] = self._bal_parent.willexecutors_list[ + edit_key + ] + del self._bal_parent.willexecutors_list[edit_key] + if col == self.Columns.BASE_FEE: + self._bal_parent.willexecutors_list[edit_key]["base_fee"] = ( + Util.encode_amount(text, self.get_decimal_point()) + ) + if col == self.Columns.ADDRESS: + self._bal_parent.willexecutors_list[edit_key]["address"] = text + if col == self.Columns.INFO: + self._bal_parent.willexecutors_list[edit_key]["info"] = text + self._bal_parent.save_willexecutors() + self.update() + except Exception: + pass + + def update(self): + if self._bal_parent.willexecutors_list is None: + return + try: + current_key = self.get_role_data_for_current_item( + col=self.Columns.URL, role=self.ROLE_HEIR_KEY + ) + self.model().clear() + self.update_headers(self.__class__.headers) + + set_current = None + + for url, value in self._bal_parent.willexecutors_list.items(): + labels = [""] * len(self.Columns) + labels[self.Columns.URL] = url + if Willexecutors.is_selected(value): + + labels[self.Columns.SELECTED] = [ + read_QIcon_from_bytes( + self._bal_parent.bal_plugin.read_file("icons/confirmed.png") + ), + "", + ] + else: + labels[self.Columns.SELECTED] = "" + labels[self.Columns.BASE_FEE] = Util.decode_amount( + value.get("base_fee", 0), self.get_decimal_point() + ) + if str(value.get("status", 0)) == "200": + labels[self.Columns.STATUS] = [ + read_QIcon_from_bytes( + self._bal_parent.bal_plugin.read_file( + "icons/status_connected.png" + ) + ), + "", + ] + else: + labels[self.Columns.STATUS] = [ + read_QIcon_from_bytes( + self._bal_parent.bal_plugin.read_file("icons/unconfirmed.png") + ), + "", + ] + labels[self.Columns.ADDRESS] = str(value.get("address", "")) + labels[self.Columns.INFO] = str(value.get("info", "")) + + items = [] + for e in labels: + if isinstance(e, list): + try: + items.append(QStandardItem(*e)) + except Exception as e: + pass + else: + items.append(QStandardItem(e)) + items[self.Columns.SELECTED].setEditable(False) + items[self.Columns.URL].setEditable(True) + items[self.Columns.ADDRESS].setEditable(True) + items[self.Columns.INFO].setEditable(True) + items[self.Columns.BASE_FEE].setEditable(True) + items[self.Columns.STATUS].setEditable(False) + + items[self.Columns.URL].setData( + url, self.ROLE_HEIR_KEY + self.Columns.URL + ) + items[self.Columns.BASE_FEE].setData( + url, self.ROLE_HEIR_KEY + self.Columns.BASE_FEE + ) + items[self.Columns.INFO].setData( + url, self.ROLE_HEIR_KEY + self.Columns.INFO + ) + items[self.Columns.ADDRESS].setData( + url, self.ROLE_HEIR_KEY + self.Columns.ADDRESS + ) + row_count = self.model().rowCount() + self.model().insertRow(row_count, items) + if url == current_key: + idx = self.model().index(row_count, self.Columns.URL) + set_current = QPersistentModelIndex(idx) + self.set_current_idx(set_current) + self.filter() + except Exception as e: + _logger.error(f"error updating willexcutor {e}") + raise e + + + +class WillExecutorWidget(QWidget, MessageBoxMixin): + def __init__(self, parent, bal_window, willexecutors=None): + self.bal_window = bal_window + self.bal_plugin = bal_window.bal_plugin + self._bal_parent = parent + MessageBoxMixin.__init__(self) + QWidget.__init__(self, parent) + if willexecutors: + self.willexecutors_list = willexecutors + else: + self.willexecutors_list = Willexecutors.get_willexecutors(self.bal_plugin) + + self.size_label = QLabel() + self.will_executor_list_widget = WillExecutorListWidget(self) + + vbox = QVBoxLayout(self) + vbox.addWidget(self.size_label) + + widget = QWidget() + hbox = QHBoxLayout(widget) + hbox.addWidget(QLabel(_("Add transactions without willexecutor"))) + heir_no_willexecutor = BalCheckBox(self.bal_plugin.NO_WILLEXECUTOR) + hbox.addWidget(heir_no_willexecutor) + spacer_widget = QWidget() + spacer_widget.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + hbox.addWidget(spacer_widget) + vbox.addWidget(widget) + + vbox.addWidget(self.will_executor_list_widget) + buttonbox = QHBoxLayout() + + b = QPushButton(_("Add")) + b.clicked.connect(self.add) + buttonbox.addWidget(b) + + b = QPushButton(_("Download List")) + b.clicked.connect(self.download_list) + buttonbox.addWidget(b) + + b = QPushButton(_("Import")) + b.clicked.connect(self.import_file) + buttonbox.addWidget(b) + + b = QPushButton(_("Export")) + b.clicked.connect(self.export_file) + buttonbox.addWidget(b) + + b = QPushButton(_("Ping All")) + b.clicked.connect(self.update_willexecutors) + buttonbox.addWidget(b) + + vbox.addLayout(buttonbox) + # self.will_executor_list_widget.update() + + def add(self): + self.willexecutors_list["http://localhost:8080"] = { + "info": "New Will Executor", + "base_fee": 0, + "status": "-1", + } + self.will_executor_list_widget.update() + + def download_list(self, wes=None): + # Both this button and the wizard go through the same code path on + # BalWindow, which shows a "Downloading..." dialog (non-blocking GUI), + # tries the configured + fallback servers, logs the technical details + # and shows a simple message on failure. + def on_success(result): + self.willexecutors_list.update(result) + self.will_executor_list_widget.update() + Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list) + self.update() + + self.bal_window.download_list(self.bal_window.willexecutors, on_success) + + def export_file(self, path): + export_meta_gui( + self.bal_window.window, "willexecutors.json", self.export_json_file + ) + + def export_json_file(self, path): + write_json_file(path, self.willexecutors_list) + + def import_file(self): + import_meta_gui( + self.bal_window.window, + _("willexecutors"), + self.import_json_file, + self.willexecutors_list.update, + ) + + def update_willexecutors(self, wes=None): + if not wes: + wes = self.willexecutors_list + self.bal_window.ping_willexecutors(wes, self.save_willexecutors) + + def import_json_file(self, path): + data = read_json_file(path) + data = self._validate(data) + self.willexecutors_list.update(data) + self.will_executor_list_widget.update() + + # TODO validate willexecutor json import file + def _validate(self, data): + return data + + def save_willexecutors(self, wes=None): + if not wes: + wes = self.willexecutors_list + self.willexecutors_list.update(wes) + self.will_executor_list_widget.update() + Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list) + + diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py new file mode 100644 index 0000000..25a8cbe --- /dev/null +++ b/bal/gui/qt/plugin.py @@ -0,0 +1,493 @@ +""" +bal.gui.qt.plugin +================= + +The Qt entry point of the plugin. + +:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` and adds the +Electrum ``@hook`` methods that wire the plugin into the Qt GUI (status-bar +button, Tools menu, wallet load/close, settings dialog). Electrum instantiates +this class because the package ``manifest.json`` declares ``available_for: +["qt"]`` and the loader imports ``qt.py`` (a thin shim re-exporting this class). + +One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window +and cached in ``self.bal_windows``. +""" + +from electrum.gui.qt.main_window import StatusBarButton + +from .common import * +from .common import _, _logger # underscore names are not re-exported by "import *" +from .common import read_QIcon_from_bytes +from .widgets import BalCheckBox, BalLineEdit, BalTextEdit +from .window import BalWindow +from .dialogs import BalDialog + + +def _window_key(window): + """Return a stable, hashable identity for an Electrum top-level window. + + The original code used ``window.winId`` (the *bound method*, not its + result) as a dict key. That happened to work because the same window + object yields the same bound method, but it is semantically wrong and + fragile across window re-creation / multiple wallets. ``id(window)`` is a + stable, correct identity for the lifetime of the window object. + """ + return id(window) + + +class Plugin(BalPlugin): + def __init__(self, parent, config, name): + _logger.info("INIT BALPLUGIN") + BalPlugin.__init__(self, parent, config, name) + self.bal_windows = {} + # Status-bar buttons, keyed by id(sb.window()). Tracking them lets us + # remove a stale button before creating a fresh one when a wallet is + # switched / Electrum is restarted, so the icon is never duplicated. + self._statusbar_buttons = {} + + @hook + def init_qt(self, gui_object): + # Called when the plugin is enabled, including *hot* (while a wallet is + # already open). The original code gave up here and asked the user to + # restart Electrum; instead we fully initialise the already-open + # window(s) so the plugin works immediately. + _logger.info("HOOK bal init qt") + try: + self.gui_object = gui_object + for window in gui_object.windows: + self._setup_window(window, load_open_wallet=True) + except Exception as e: + _logger.error("Error loading plugin {}".format(e)) + raise e + + @staticmethod + def _close_plugins_manager_dialog(): + """Close Electrum's "Electrum Plugins" manager dialog if it is open. + + This is the native Electrum ``PluginsDialog`` (a ``WindowModalDialog``); + it is not owned by this plugin, so we locate it among the application's + top-level widgets and close it. Failures are non-fatal: leaving the + dialog open is harmless, so we never propagate exceptions from here. + """ + Plugin._handle_plugins_manager_dialog(attempt=0) + + @staticmethod + def _find_plugins_manager_dialogs(): + """Return the open Electrum "Electrum Plugins" manager dialog(s). + + The match is intentionally permissive: when our plugin is loaded from a + zip (``electrum_external_plugins``), ``isinstance`` against the imported + ``PluginsDialog`` class can fail due to differing module identities, so + we also match by class name and by window title (including the localized + title, since the user runs Electrum under a non-English locale). + """ + try: + from PyQt6.QtWidgets import QApplication + except Exception: + return [] + try: + from electrum.gui.qt.plugins_dialog import PluginsDialog + except Exception: + PluginsDialog = None + app = QApplication.instance() + if app is None: + return [] + # Accept both the English title and the translated one. We cannot rely + # only on _() because the dialog object may have been built with a + # different gettext binding than ours when loaded from a zip. + titles = {"Electrum Plugins"} + try: + titles.add(_("Electrum Plugins")) + except Exception: + pass + found = [] + for w in app.topLevelWidgets(): + try: + is_match = False + if PluginsDialog is not None and isinstance(w, PluginsDialog): + is_match = True + elif type(w).__name__ == "PluginsDialog": + is_match = True + elif w.windowTitle() in titles: + is_match = True + if not is_match: + continue + # Only count it as "open" if it is actually visible: after a + # successful close()/reject() the QDialog object still lives in + # topLevelWidgets() but becomes invisible, so filtering by + # isVisible() is what tells "still open" from "already closed". + visible = w.isVisible() + _logger.info( + "plugins manager dialog match: cls={} title={!r} " + "visible={}".format( + type(w).__name__, w.windowTitle(), visible + ) + ) + if visible: + found.append(w) + except Exception as e: + _logger.debug("inspecting top-level widget failed: {}".format(e)) + return found + + @staticmethod + def _try_dismiss_dialog(d): + """Attempt to dismiss a (possibly modal) dialog as robustly as we can. + + A ``PluginsDialog`` is opened with ``exec()`` (a nested, *application- + modal* event loop). Inside such a loop a plain ``close()`` is not + always honoured, so we also try ``reject()`` / ``done()`` which end the + modal loop directly. Any of these may fail depending on Qt state, so + each is guarded independently. + """ + try: + from PyQt6.QtWidgets import QDialog + except Exception: + QDialog = None + # 1) reject() / done(): the reliable way to end an exec() modal loop. + if QDialog is not None and isinstance(d, QDialog): + try: + d.reject() + except Exception as e: + _logger.debug("reject() failed: {}".format(e)) + try: + d.done(QDialog.DialogCode.Rejected) + except Exception as e: + _logger.debug("done() failed: {}".format(e)) + # 2) close(): covers non-QDialog top-levels and is a harmless extra. + try: + d.close() + except Exception as e: + _logger.debug("could not close plugins dialog: {}".format(e)) + + @staticmethod + def _handle_plugins_manager_dialog(attempt=0): + """Try to auto-close the manager dialog; retry a few times. + + Enabling the plugin happens while Electrum's ``PluginsDialog`` may still + be running its own modal event loop, so a single ``close()`` can be + ignored. We retry on a short schedule and, if it is still open after the + last attempt, fall back to bringing it to the front so the user notices + it and closes it themselves (it must not linger in the background). + """ + try: + from PyQt6.QtCore import QTimer + except Exception: + QTimer = None + # Schedule of retry delays (ms) measured from each call. + retry_delays = [400, 800, 1500] + dialogs = Plugin._find_plugins_manager_dialogs() + _logger.info( + "auto-close plugins dialog: attempt={} found={}".format( + attempt, len(dialogs) + ) + ) + for d in dialogs: + Plugin._try_dismiss_dialog(d) + # Re-check: anything still visible? + still_open = Plugin._find_plugins_manager_dialogs() + if not still_open: + _logger.info("plugins dialog closed successfully") + return + if attempt < len(retry_delays) and QTimer is not None: + QTimer.singleShot( + retry_delays[attempt], + lambda: Plugin._handle_plugins_manager_dialog(attempt + 1), + ) + return + # Final fallback: we could not close it -> at least raise it to the + # front so it does not stay hidden in the background. + _logger.info( + "could not close plugins dialog after {} attempts; " + "bringing it to front".format(attempt + 1) + ) + for d in still_open: + try: + d.showNormal() + d.raise_() + d.activateWindow() + except Exception as e: + _logger.debug("could not raise plugins dialog: {}".format(e)) + + def _setup_window(self, window, *, load_open_wallet): + """Create the BalWindow for *window* and wire its menu (and, when + enabling hot, the already-open wallet). + + This mirrors what the ``init_menubar`` + ``load_wallet`` hooks do at + normal startup, so enabling the plugin while a wallet is open no longer + requires restarting Electrum. + """ + w = self.get_window(window) + # Use Electrum's official tools_menu instead of searching the menubar + # for a menu whose *translated* title equals "&Tools" (which breaks + # under non-English locales). + tools_menu = getattr(window, "tools_menu", None) + if tools_menu is not None: + try: + w.init_menubar_tools(tools_menu) + except Exception as e: + _logger.error("init_qt: failed wiring tools menu: {}".format(e)) + if load_open_wallet and getattr(window, "wallet", None): + # Replicate load_wallet() for the wallet that is already open. + try: + w.wallet = window.wallet + w.init_will() + w.willexecutors = Willexecutors.get_willexecutors( + self, update=False, bal_window=w + ) + w.disable_plugin = False + w.ok = True + except Exception as e: + _logger.error("init_qt: failed initialising open wallet: {}".format(e)) + return w + + @hook + def create_status_bar(self, sb): + # Show the BAL icon in the status bar (bottom-right): it signals that + # the Bitcoin After Life plugin is installed and, when clicked, quickly + # opens the plugin settings (settings_dialog). + # + # NOTE: this was NOT the "condensed menu/tabs" bug under the Electrum + # logo -- that one was a Windows OverflowError (year 2038), fixed + # separately. The icon must therefore be kept. + # + # To avoid a duplicated icon on restart / wallet switch, we track the + # button by id(sb.window()) and remove the stale one before creating a + # fresh one. + _logger.info("HOOK create status bar") + key = id(sb.window()) + old = self._statusbar_buttons.pop(key, None) + if old is not None: + try: + old.setParent(None) + old.deleteLater() + except Exception: + pass + b = StatusBarButton( + read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")), + "Bal " + _("Bitcoin After Life"), + lambda: self.settings_dialog(sb.window()), + sb.height(), + ) + sb.addPermanentWidget(b) + self._statusbar_buttons[key] = b + + # When the plugin is enabled "hot" from Tools -> Plugins, Electrum keeps + # its "Electrum Plugins" manager dialog open and even calls + # bring_to_front on it. Enabling triggers reload_windows(), which + # recreates the window and therefore fires this create_status_bar hook; + # that makes this the right place to auto-close the leftover manager + # dialog (Electrum 4.7.x no longer calls the old init_qt hook). + # + # We use a QTimer so this runs *after* Electrum's own bring_to_front + # (QTimer.singleShot(100, ...)); a slightly larger delay makes our close + # win. On a normal startup no PluginsDialog is open, so the helper is a + # harmless no-op. + QTimer.singleShot(250, self._close_plugins_manager_dialog) + + @hook + def init_menubar(self, window): + _logger.info("HOOK init_menubar") + w = self.get_window(window) + w.init_menubar_tools(window.tools_menu) + # Also try here: init_menubar is one of the hooks fired when Electrum + # recreates the window during a hot enable (reload_windows()), so it is + # another reliable trigger to auto-close the leftover manager dialog. + QTimer.singleShot(300, self._close_plugins_manager_dialog) + + @hook + def load_wallet(self, wallet, main_window): + _logger.debug("HOOK load wallet") + w = self.get_window(main_window) + # havetoupdate = Util.fix_will_settings_tx_fees(wallet.db) + w.wallet = wallet + w.init_will() + w.willexecutors = Willexecutors.get_willexecutors( + self, update=False, bal_window=w + ) + w.disable_plugin = False + w.ok = True + # load_wallet is fired on the recreated window during a hot enable too; + # use it as an extra trigger to auto-close the leftover manager dialog. + QTimer.singleShot(350, self._close_plugins_manager_dialog) + + @hook + def close_wallet(self, wallet): + _logger.debug("HOOK close wallet") + # Iterate over a snapshot: on_close() may mutate the GUI/state. + for win in list(self.bal_windows.values()): + if getattr(win, "wallet", None) == wallet: + try: + win.on_close() + except Exception as e: + _logger.error("close_wallet: on_close failed: {}".format(e)) + + @hook + def init_keystore(self): + _logger.debug("init keystore") + + @hook + def daemon_wallet_loaded(self, boh, wallet): + _logger.debug("daemon wallet loaded") + + def get_window(self, window): + window = window.top_level_window() + key = _window_key(window) + w = self.bal_windows.get(key, None) + if w is None: + w = BalWindow(self, window) + self.bal_windows[key] = w + return w + + def requires_settings(self): + return True + + def settings_widget(self, window): + + w = self.get_window(window.window) + widget = QWidget() + enterbutton = EnterButton(_("Settings"), partial(w.settings_dialog, window)) + + widget.setLayout(Buttons(enterbutton, widget)) + return widget + + def password_dialog(self, msg=None, parent=None): + parent = parent or self + d = PasswordDialog(parent, msg) + return d.run() + + def get_seed(self): + password = None + if self.wallet.has_keystore_encryption(): + password = self.password_dialog(parent=self.d.parent()) + if not password: + raise UserCancelled() + + keystore = self.wallet.get_keystore() + if not keystore or not keystore.has_seed(): + return + self.extension = bool(keystore.get_passphrase(password)) + return keystore.get_seed(password) + + def settings_dialog(self, window=None, wallet=None): + + d = BalDialog(window, self, self.get_window_title("Settings")) + d.setMinimumSize(100, 200) + qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png")) + lbl_logo = QLabel() + lbl_logo.setPixmap(qicon) + + # heir_ping_willexecutors = BalCheckBox(self.PING_WILLEXECUTORS) + # heir_ask_ping_willexecutors = BalCheckBox(self.ASK_PING_WILLEXECUTORS) + # heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR) + + def on_multiverse_change(): + self.update_all() + + # heir_enable_multiverse = BalCheckBox(self.ENABLE_MULTIVERSE,on_multiverse_change) + + heir_hide_replaced = BalCheckBox(self.HIDE_REPLACED, on_multiverse_change) + + heir_hide_invalidated = BalCheckBox(self.HIDE_INVALIDATED, on_multiverse_change) + heir_auto_sign = BalCheckBox(self.AUTO_SIGN, on_multiverse_change) + heir_repush = QPushButton("Rebroadcast transactions") + heir_repush.clicked.connect(partial(self.broadcast_transactions, True)) + + grid = QGridLayout(d) + + heir_alarm_number = QSpinBox() + heir_alarm_number.setMinimum(0) + heir_alarm_number.setMaximum(100) + heir_alarm_number.setValue(self.ALARM_NUMBER.get()) + def on_alarm_number_changed(value): + self.ALARM_NUMBER.set(value) + heir_alarm_number.valueChanged.connect(on_alarm_number_changed) + + add_widget( + grid, + "Hide Replaced", + heir_hide_replaced, + 1, + "Hide replaced transactions from will detail and list", + ) + add_widget( + grid, + "Hide Invalidated", + heir_hide_invalidated, + 2, + "Hide invalidated transactions from will detail and list", + ) + add_widget( + grid, + "Auto Sign", + heir_auto_sign, + 3, + "Automatically sign transactions when Check is pressed (requires password)", + ) + add_widget( + grid, + "Calendar App", + BalLineEdit(self.CALENDAR_APP), + 4, + "Default app used to open calendar", + ) + add_widget( + grid, + "Event summary", + BalLineEdit(self.EVENT_SUMMARY), + 5, + ( + "Default message to be used in event summary\n" + "Variables:\n" + " $wallet_name: name of wallet\n" + " $heirs_complete: list of heirs name,address,amount\n" + ) + ) + add_widget( + grid, + "Event description", + BalTextEdit(self.EVENT_DESCRIPTION), + 6, + ( + "Default message to be used in event description\n" + "Variables:\n" + " $wallet_name: name of wallet\n" + " $heirs_complete: list of heirs name,address,amount\n" + ) + ) + add_widget( + grid, + "Number of alarms", + heir_alarm_number, + 7, + "Number of calendar alarms before the deadline (default 3)", + ) + grid.addWidget(heir_repush, 8, 0) + grid.addWidget( + HelpButton( + "Broadcast all transactions to willexecutors including those already pushed" + ), + 8, + 2, + ) + + if ret := bool(show_modal(d)): + try: + self.update_all() + return ret + except Exception: + pass + return False + + def broadcast_transactions(self, force): + for _k, w in self.bal_windows.items(): + w.broadcast_transactions(force) + + def update_all(self): + for _k, w in self.bal_windows.items(): + w.update_all() + + def get_window_title(self, title): + return _("BAL - ") + _(title) + + diff --git a/bal/gui/qt/theme.py b/bal/gui/qt/theme.py new file mode 100644 index 0000000..189afa5 --- /dev/null +++ b/bal/gui/qt/theme.py @@ -0,0 +1,97 @@ +""" +bal.gui.qt.theme +================ + +Pure presentation helpers for the Qt layer. + +This is where colours and other look-and-feel decisions live, kept apart from +the core inheritance logic. In particular it hosts :func:`status_color`, which +used to be ``WillItem.get_color()`` inside ``will.py``. + +The status flags themselves are computed by the core layer +(:class:`bal.core.will.WillItem`); this module only translates a will item's +status into a colour for the transaction list / detail views. +""" + +# Status -> hex colour. The first matching status (checked in priority order) +# wins. These are exactly the colours the original ``WillItem.get_color`` used, +# so the GUI looks identical after the refactor. +# +# The order matters: e.g. an INVALIDATED tx must show orange even if it also +# carries other flags, so INVALIDATED is checked before everything else. +_STATUS_COLOR_PRIORITY = ( + ("INVALIDATED", "#f87838"), # orange - tx can no longer be mined + ("REPLACED", "#ff97e9"), # pink - superseded by a lower-locktime tx + ("CONFIRMED", "#bfbfbf"), # grey - already mined + ("PENDING", "#ffce30"), # yellow - tx is in the mempool (unconfirmed) +) + +# Default colour used when no status in the priority list matches. +_DEFAULT_COLOR = "#ffffff" + + +def status_color(will_item) -> str: + """Return the display colour (``"#rrggbb"``) for a :class:`WillItem`. + + This is a faithful, behaviour-preserving port of the old + ``WillItem.get_color()`` method. The slightly irregular handling of the + push/check states (which is not a simple priority list) is reproduced + exactly as in the original code. + """ + # First, the simple priority-ordered statuses. + for status, color in _STATUS_COLOR_PRIORITY: + if will_item.get_status(status): + return color + + # The remaining states need the original branching because of the + # CHECK_FAIL / CHECKED interaction. + if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"): + return "#e83845" # red - server check failed + elif will_item.get_status("CHECKED"): + return "#8afa6c" # green - server confirmed it stored the tx + elif will_item.get_status("PUSH_FAIL"): + return "#e83845" # red - failed to push to will-executor + elif will_item.get_status("PUSHED"): + return "#73f3c8" # teal - pushed to will-executor + elif will_item.get_status("COMPLETE"): + return "#2bc8ed" # blue - signed + else: + return _DEFAULT_COLOR + + +def server_status_text(will_item) -> str: + """Return a short, human-readable label describing the state of a will + item on the will-executor servers (the online inheritance backup). + + This is shown in the dedicated "Server" column of the transaction list so + the user always knows whether each inheritance transaction is actually + stored on the will-executor servers, regardless of the row colour. + """ + from electrum.i18n import _ + + if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"): + return _("Not on server") + if will_item.get_status("CHECKED"): + return _("Confirmed on server") + if will_item.get_status("PUSH_FAIL"): + return _("Send failed") + if will_item.get_status("PUSHED"): + return _("Sent (not checked)") + if will_item.get_status("COMPLETE"): + return _("Signed (not sent)") + return _("Not sent") + + +def server_status_tooltip(will_item) -> str: + """Return a detailed tooltip for the "Server" column, including the + will-executor URL (if any) and the current server state.""" + from electrum.i18n import _ + + url = None + we = getattr(will_item, "we", None) + if we: + url = we.get("url") + state = server_status_text(will_item) + if url: + return "{}: {}\n{}".format(_("Will-Executor"), url, state) + return "{}\n{}".format(_("No will-executor"), state) diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py new file mode 100644 index 0000000..0862963 --- /dev/null +++ b/bal/gui/qt/widgets.py @@ -0,0 +1,921 @@ +""" +bal.gui.qt.widgets +================== + +Reusable, self-contained Qt widgets used to build the BAL tabs and dialogs. + +These are "leaf" widgets: they receive the :class:`BalWindow` controller (and +any data they need) as constructor arguments at runtime, so this module does +not import ``window``/``dialogs`` and therefore introduces no import cycles. + +Contents: + * ClickableLabel, BalLineEdit, BalTextEdit, BalCheckBox - thin Qt wrappers + * BalTxFeesWidget - fee-rate editor + * _LockTimeEditor + BalTimeEditWidget + raw/date editors - locktime editing + * ThresholdTimeWidget / LockTimeWidget - threshold & locktime + * WillSettingsWidget - the settings panel + * PercAmountEdit - amount-or-percentage editor + * WillWidget - single will-tx box +""" + +from .common import * +from .common import _, _logger, Will +from .calendar import BalCalendar + + +class ClickableLabel(QLabel): + doubleClicked = pyqtSignal() + + def mouseDoubleClickEvent(self, event): + self.doubleClicked.emit() + super().mouseDoubleClickEvent(event) + + + +class BalTxFeesWidget(QWidget): + valueChanged = pyqtSignal() + current_value = None + + def __init__(self, bal_window, parent, value=None): + super().__init__(parent) + self.bal_window = bal_window + layout = QHBoxLayout(self) + self.txfee_widget = QSpinBox(self) + self.txfee_widget.setMinimum(1) + self.txfee_widget.setMaximum(10000) + value = ( + value + if value + else self.bal_window.bal_plugin.WILL_SETTINGS.get()["baltx_fees"] + ) + self.set_value(value) + self.default_value = self.bal_window.bal_plugin.default_will_settings()[ + "baltx_fees" + ] + self.txfee_widget.valueChanged.connect(self.on_heir_tx_fees) + #label = ClickableLabel("$") + #label.doubleClicked.connect(self.doubleclick) + #layout.addWidget(label) + button = HelpButton(_("mining fees expressed in sats/vbyte to be used in the Bitcoin transaction.\nHigher value ensure your transaction will be confirmed")) + button.setText("丰") + button.setStyleSheet("font-size: 16px;") + layout.addWidget(button) + layout.addWidget(self.txfee_widget) + # Expose the leading icon (prefix) and the editable field so the parent + # WillSettingsWidget can align them on a grid (see its vertical layout). + self.prefix_widget = button + self.field_widget = self.txfee_widget + + def doubleclick(self, event=None): + pass + + def set_read_only(self, read_only=True): + # Show the fee but make it non-editable (no spin arrows, no keyboard), + # so it can only be changed from the "Build your will" wizard. + self.txfee_widget.setReadOnly(read_only) + self.txfee_widget.setButtonSymbols( + QAbstractSpinBox.ButtonSymbols.NoButtons + if read_only + else QAbstractSpinBox.ButtonSymbols.UpDownArrows + ) + # Light-grey background when locked, so the read-only state is visible + # (same look as the date fields); empty stylesheet restores the + # editable appearance used inside the wizard. + self.txfee_widget.setStyleSheet( + "QSpinBox{background-color:#f0f0f0;}" if read_only else "" + ) + + def get_value(self): + return self.txfee_widget.value() + + def set_value(self, value, emit=True): + value = int(value) if value is not None else 20 + if getattr(self, "_updating", False): + return + + self._updating = True + try: + self.current_value = value + spin = self.txfee_widget + spin.blockSignals(True) + spin.setValue(value) + spin.blockSignals(False) + + finally: + self._updating = False + + if emit: + spin.valueChanged.emit(value) + + def on_heir_tx_fees(self, value=None, update_all=True): + if value != self.current_value: + try: + self.set_value(value) + if update_all: + self.bal_window.update_setting_widgets( + self.get_value(), "baltx_fees", True + ) + except Exception as e: + _logger.error(f"error while trying to update txfees{e}") + log_error(e) + else: + pass + + + +class _LockTimeEditor: + min_allowed_value = NLOCKTIME_MIN + max_allowed_value = NLOCKTIME_MAX + alarm = None + + def get_value(self) -> Optional[int]: + raise NotImplementedError() + + def set_value(self, x: Any, force=True) -> None: + raise NotImplementedError() + + @classmethod + def is_acceptable_locktime(cls, x: Any) -> bool: + if not x: # e.g. empty string + return True + try: + x = int(x) + except Exception as _e: + return False + return cls.min_allowed_value <= x <= cls.max_allowed_value + + @staticmethod + def get_max_allowed_timestamp() -> int: + ts = NLOCKTIME_MAX + # Test if this value is within the valid timestamp limits (which is platform-dependent). + # see #6170 + try: + datetime.fromtimestamp(ts) + except (OSError, OverflowError): + ts = 2**31 - 1 # INT32_MAX + datetime.fromtimestamp(ts) # test if raises + return ts + + + +class BalTimeEditWidget(QWidget, _LockTimeEditor): + valueEdited = pyqtSignal() + _setting_locktime = False + current_value = None + current_index = None + default_value = None + + help_text = ( + "if you choose Raw, you can insert various options based on suffix:\n" + + " - d: number of days after current day(ex: 1d means tomorrow)\n" + + " - y: number of years after currrent day(ex: 1y means one year from today)\n" + ) + label_text = None + tooltip_text = None + base_field = None + + def __init__(self, bal_window, parent, default_locktime=None): + super().__init__(parent) + self.bal_window = bal_window + + hbox = QHBoxLayout() + self.setLayout(hbox) + hbox.setContentsMargins(0, 0, 0, 0) + hbox.setSpacing(0) + self.setMinimumWidth(40 * char_width_in_lineedit()) + self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self) + self.locktime_date_e = LockTimeDateEdit(self, time_edit=self) + self.editors = [self.locktime_raw_e, self.locktime_date_e] + self.combo = QComboBox() + options = [_("Raw"), _("Date")] + self.option_index_to_editor_map = { + 0: self.locktime_raw_e, + 1: self.locktime_date_e, + } + self.combo.addItems(options) + default_index = 0 + if not default_locktime: + default_locktime = self.bal_window.bal_plugin.WILL_SETTINGS.get()[self.base_field] + try: + int(default_locktime) + default_index = 1 + except Exception: + default_index = 0 + #hbox.addWidget(QLabel(self.label_text)) + help_button=HelpButton(self.help_text) + help_button.setText(self.label_text) + # Show a short label (e.g. "Delivery time" / "Check Alive") when the + # user hovers the icon, so the emoji button is self-explanatory. + if self.tooltip_text: + help_button.setToolTip(_(self.tooltip_text)) + #help_button.setStyleSheet("font-size: 155555); + hbox.addWidget(help_button) + # Expose the leading icon (prefix) so the parent WillSettingsWidget can + # align all rows on a common left edge (see its vertical layout). + self.prefix_widget = help_button + self.combo.currentIndexChanged.connect(self.on_current_index_changed) + + for w in self.editors: + w.setVisible(False) + w.setEnabled(False) + + self.editor = self.option_index_to_editor_map[default_index] + self.editor.setVisible(True) + self.editor.setEnabled(True) + self.set_index(default_index) + #self.on_current_index_changed(default_index) + self.set_value(default_locktime) + self.current_value=default_locktime + hbox.addWidget(self.combo) + for w in self.editors: + hbox.addWidget(w) + + hbox.addStretch(1) + # spssscer_widget = QWidget() + # spacer_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + # hbox.addWidget(spacer_widget) + self.valueEdited.connect(lambda: self.update_will_settings(True)) + self.locktime_raw_e.editingFinished.connect(self.valueEdited.emit) + self.locktime_date_e.dateTimeChanged.connect(self.valueEdited.emit) + #self.combo.currentIndexChanged.connect(self.valueEdited.emit) + + def update_will_settings( + self, + update_all=False, + update_will_dialog=False, + update_heirs_dialog=False, + ): + self.bal_window.update_setting_widgets( + self.get_value(), + self.base_field, + update_all, + update_will_dialog, + update_heirs_dialog, + ) + + def on_current_index_changed(self, i): + self.current_index = i + for w in self.editors: + w.setVisible(False) + w.setEnabled(False) + # prev_locktime = self.editor.get_value() + self.editor = self.option_index_to_editor_map[i] + if i==0: + self.editor.set_value(self.bal_window.bal_plugin.default_will_settings_relative()[self.base_field]) + else: + self.editor.set_value(self.bal_window.bal_plugin.default_will_settings_absolute()[self.base_field]) + self.valueEdited.emit() + # if self.editor.is_acceptable_locktime(prev_locktime): + # self.editor.set_value(prev_locktime, force=False) + self.editor.setVisible(True) + self.editor.setEnabled(True) + self.bal_window.update_combo_setting_widgets(i, self.base_field,True) + + def get_value(self) -> Optional[str]: + val = self.editor.get_value() + #return self.current_value + return val + + def set_index(self, index): + if self.current_index != index: + self.combo.setCurrentIndex(index) + #self.on_current_index_changed(index, force) + + def set_value( + self, + x: Any, + force=None, + update_all=False, + update_will_dialog=False, + update_heirs_dialog=False, + ) -> None: + if not x: + if self.current_index == 0: + x = self.bal_window.bal_plugin.default_will_settings_relative()[self.base_field] + elif self.current_index == 1: + x = self.bal_window.bal_plugin.default_will_settings_absolute()[self.base_field] + if x != self.get_value(): + self.editor.set_value(x) + self.current_value = x + self.bal_window.update_setting_widgets(x, self.base_field) + + def set_read_only(self, read_only=True): + """Show the value but make it non-editable. + + Used everywhere except the "Build your will" wizard, where the date is + the only place the user is allowed to change it. The Raw/Date combo is + disabled and both editors become read-only with no spin buttons. + """ + self.combo.setEnabled(not read_only) + for w in self.editors: + w.set_read_only(read_only) + + + +class TimeRawEditWidget(QWidget): + editingFinished = pyqtSignal() + + def is_acceptable_locktime(self, value): + return True + + def __init__(self, parent, time_edit=None): + super().__init__(parent) + self.editor = LockTimeRawEdit(parent, time_edit) + self.label = QLabel("") + self.label.setFixedWidth(10 * char_width_in_lineedit()) + self.layout = QHBoxLayout(self) + self.layout.addWidget(self.editor) + self.layout.addWidget(self.label) + self.editor.editingFinished.connect(self.editingFinished.emit) + self.get_value = self.editor.get_value + self.set_value = self.editor.set_value + + def set_read_only(self, read_only=True): + self.editor.setReadOnly(read_only) + # Match the Date editor: grey background when locked so the read-only + # state is visible; empty stylesheet restores the editable look. + self.editor.setStyleSheet( + "QLineEdit{background-color:#f0f0f0;}" if read_only else "" + ) + + + +class LockTimeRawEdit(QLineEdit, _LockTimeEditor): + def __init__(self, parent=None, time_edit=None): + QLineEdit.__init__(self, parent) + self.setFixedWidth(12 * char_width_in_lineedit()) + self.textChanged.connect(self.numbify) + self.isdays = False + self.isyears = False + self.time_edit = time_edit + + @staticmethod + def replace_str(text): + return str(text).replace("d", "").replace("y", "") + + def checkbdy(self, s, pos, appendix): + try: + charpos = pos - 1 + charpos = max(0, charpos) + charpos = min(len(s) - 1, charpos) + if appendix == s[charpos]: + s = self.replace_str(s) + appendix + pos = charpos + except Exception: + pass + return pos, s + + def numbify(self): + text = self.text().strip() + chars = "0123456789dy" + pos = self.cursorPosition() + pos = len("".join([i for i in text[:pos] if i in chars])) + s = "".join([i for i in text if i in chars]) + self.isdays = False + self.isyears = False + self.isblocks = False + + pos, s = self.checkbdy(s, pos, "d") + pos, s = self.checkbdy(s, pos, "y") + + if "d" in s: + self.isdays = True + if "y" in s: + self.isyears = True + + if self.isdays: + s = self.replace_str(s) + "d" + if self.isyears: + s = self.replace_str(s) + "y" + self.blockSignals(True) + self.setText(s) + self.blockSignals(False) + self.current_value = s + self.setModified(self.hasFocus()) + self.setCursorPosition(pos) + + def get_value(self) -> Optional[str]: + try: + return str(self.text()) + except Exception: + return None + + def set_value(self, x: Any, force=True) -> None: + if x != self.get_value(): + self.blockSignals(True) + self.setText(str(x)) + self.blockSignals(False) + self.numbify() + + + +class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor): + min_allowed_value = 0 + max_allowed_value = _LockTimeEditor.get_max_allowed_timestamp() + + def __init__(self, parent=None, time_edit=None): + QDateTimeEdit.__init__(self, parent) + self.setMinimumDateTime(datetime.fromtimestamp(self.min_allowed_value)) + self.setMaximumDateTime(datetime.fromtimestamp(self.max_allowed_value)) + #self.setDateTime(QDateTime.currentDateTime()) + self.time_edit = time_edit + + def set_read_only(self, read_only=True): + # Read-only display: keyboard editing disabled and the up/down spin + # arrows removed, so the date can only be changed from the wizard. + self.setReadOnly(read_only) + self.setButtonSymbols( + QAbstractSpinBox.ButtonSymbols.NoButtons + if read_only + else QAbstractSpinBox.ButtonSymbols.UpDownArrows + ) + # A read-only QDateTimeEdit keeps a white background by default, which + # does not visually signal that it is locked. Paint it light grey (like + # the disabled combo/fee fields next to it) so the user sees at a glance + # that the date is not editable here; an empty stylesheet restores the + # default look when the field is made editable again (in the wizard). + self.setStyleSheet( + "QDateTimeEdit{background-color:#f0f0f0;}" if read_only else "" + ) + + def get_value(self) -> Optional[int]: + #dt = self.dateTime().toPyDateTime() + #locktime = int(time.mktime(dt.timetuple())) + #p# + #dt = dt_edit.dateTime() + ## QDateTimets = dt.toSecsSinceEpoch() + + dt = self.dateTime() + _ts = dt.toSecsSinceEpoch() + + return _ts + + + def set_value(self, x: Any, force=False) -> None: + if not self.is_acceptable_locktime(x): + self.setDateTime(QDateTime.currentDateTime()) + return + try: + x = int(x) + except Exception as e: + x = QDateTime.currentDateTime().timestamp() + finally: + # Use the overflow-safe converter: on Windows datetime.fromtimestamp + # raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX). + _dt = BalTimestamp._safe_fromtimestamp(x) + #if self.alarm != dt: + self.setDateTime(_dt) + self.alarm = _dt + + + +class ThresholdTimeWidget(BalTimeEditWidget): + # rich_text=True is used by the HelpButton, so HTML tags (,
) render. + help_text = ( + "CHECK ALIVE

" + "Check to ask for invalidation.

" + "When less then this time is missing, ask to invalidate.
" + "If you fail to invalidate during this time, your transactions will be delivered to your heirs.

" + "if you choose Raw, you can insert various options based on suffix:
" + " - d: number of days after current day(ex: 1d means tomorrow)
" + " - y: number of years after currrent day(ex: 1y means one year from today)
" + ) + label_text = "🚨" + #label_text = "Check Alive" + tooltip_text = "Check Alive" + base_field = "threshold" + + def __init__(self, bal_window, parent, init_value=None): + if init_value is None: + init_value = bal_window.bal_plugin.WILL_SETTINGS.get()["threshold"] + super().__init__(bal_window, parent, init_value) + self.default_value = self.bal_window.bal_plugin.default_will_settings()[ + "threshold" + ] + + + +class LockTimeWidget(BalTimeEditWidget): + # rich_text=True is used by the HelpButton, so HTML tags (,
) render. + help_text = ( + "DELIVERY TIME

" + "Set Locktime for transactions.
" + "Any time is needed transaction will be anticipated by 1day

" + "if you choose Raw, you can insert various options based on suffix:
" + " - d: number of days after current day(ex: 1d means tomorrow)
" + " - y: number of years after currrent day(ex: 1y means one year from today)
" + ) + label_text = "🚛" + #label_text = "Locktime" + tooltip_text = "Delivery time" + base_field = "locktime" + + def __init__(self, bal_window, parent, init_value=None): + if init_value is None: + init_value = bal_window.bal_plugin.WILL_SETTINGS.get()["locktime"] + super().__init__(bal_window, parent, init_value) + self.default_value = self.bal_window.bal_plugin.default_will_settings()[ + "locktime" + ] + + + +class WillSettingsWidget(QWidget): + + def __init__(self, bal_window: "BalWindow", parent, layout_type="h", + read_only=True): + self.widgets = {} + QWidget.__init__(self, parent) + self.bal_window = bal_window + # When read_only=True (toolbars, Heirs tab) the delivery time, check + # alive and fee fields are display-only; they can only be edited from + # the "Build your will" wizard, which passes read_only=False. + self.read_only = read_only + box = QHBoxLayout(self) if layout_type == "h" else QVBoxLayout(self) + + self.calendar_button = QPushButton() + self.calendar_button.setIcon( + read_QIcon_from_bytes( + self.bal_window.bal_plugin.read_file("icons/calendar.png") + ) + ) + # Tooltip so the icon is self-explanatory when hovered. + self.calendar_button.setToolTip(_("Calendar")) + self.calendar_button.clicked.connect(self.open_or_save_calendar) + self.widgets["locktime"] = LockTimeWidget(bal_window, self) + self.widgets["threshold"] = ThresholdTimeWidget(bal_window, self) + self.widgets["locktime"].valueEdited.connect(self.on_locktime_change) + self.widgets["threshold"].valueEdited.connect(self.on_locktime_change) + # self.widgets['baltx_fees'].valueChange.connect(self.bal_window.update_setting_widgets) + self.on_locktime_change() + self.widgets["baltx_fees"] = BalTxFeesWidget(bal_window, self) + if not hasattr(bal_window, "txfee_widgets"): + bal_window.txfee_widgets = [] + + w = self.widgets["baltx_fees"] + if w not in bal_window.txfee_widgets: + bal_window.txfee_widgets.append(w) + if layout_type == "h": + box.addWidget(self.widgets["locktime"]) + box.addWidget(self.widgets["threshold"]) + box.addWidget(self.calendar_button) + box.addWidget(self.widgets["baltx_fees"]) + else: + # Vertical layout (the "Build your will" wizard): make every row the + # same width and left aligned so they all fit in one tidy block, + # instead of letting the calendar button and the fee field stretch to + # the dialog's right edge (which made them far wider than the date + # rows above). + # + # IMPORTANT: the leading icons keep their ORIGINAL size. The icons + # are HelpButtons, which already pin themselves to a fixed width + # (2.2 * char_width_in_lineedit()); we must NOT widen them, otherwise + # they look oversized compared with the original toolbar layout. We + # only need to (1) align the calendar row's left edge with the icons' + # original width and (2) cap every row to the date-row width. + locktime_w = self.widgets["locktime"] + threshold_w = self.widgets["threshold"] + fees_w = self.widgets["baltx_fees"] + + # Original icon width (HelpButton's own fixed width); used only to + # offset the calendar button so its field starts under the others. + icon_w = locktime_w.prefix_widget.sizeHint().width() + + # Common row width = natural width of the date rows (the reference). + row_w = max( + locktime_w.sizeHint().width(), + threshold_w.sizeHint().width(), + ) + for w in (locktime_w, threshold_w, fees_w): + w.setFixedWidth(row_w) + + # The calendar row has no prefix icon: wrap it so it starts with an + # empty spacer of the icon width (calendar field aligned with the + # date/fee fields) and cap it to the same total width as the rows + # above, so it no longer stretches to the dialog's right edge. + calendar_row = QWidget(self) + calendar_box = QHBoxLayout(calendar_row) + calendar_box.setContentsMargins(0, 0, 0, 0) + calendar_box.setSpacing(0) + calendar_spacer = QWidget() + calendar_spacer.setFixedWidth(icon_w) + calendar_box.addWidget(calendar_spacer) + calendar_box.addWidget(self.calendar_button) + calendar_row.setFixedWidth(row_w) + + box.addWidget(locktime_w, alignment=Qt.AlignmentFlag.AlignLeft) + box.addWidget(threshold_w, alignment=Qt.AlignmentFlag.AlignLeft) + box.addWidget(calendar_row, alignment=Qt.AlignmentFlag.AlignLeft) + box.addWidget(fees_w, alignment=Qt.AlignmentFlag.AlignLeft) + + if self.read_only: + self.widgets["locktime"].set_read_only(True) + self.widgets["threshold"].set_read_only(True) + self.widgets["baltx_fees"].set_read_only(True) + + def create_alarms(self, alarm_start, alarm_end): + """Delegate to the pure :meth:`BalCalendar.build_alarms` helper. + + The reminder count comes from the plugin's ``ALARM_NUMBER`` setting; the + actual VALARM generation lives in ``BalCalendar`` so it can be unit + tested without a Qt widget. + """ + num_alarms = self.bal_window.bal_plugin.ALARM_NUMBER.get() + return BalCalendar.build_alarms(num_alarms, alarm_start, alarm_end) + + def open_or_save_calendar(self): + now = BalCalendar.format_time(datetime.now()) + + locktime = self.widgets["locktime"].alarm + threshold = self.widgets["threshold"].alarm + # Use the larger of current time and threshold as alarm start + alarm_start_dt = max(datetime.now(), threshold) + alarm_end_dt = locktime + alarm_end = BalCalendar.format_time(alarm_end_dt) + + heirs_details = "\r\n".join(f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}" for heir in self.bal_window.heirs) + event_description = BalCalendar.ical_escape( + f"{self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()}".replace("$wallet_name",str(self.bal_window.wallet)).replace("$heirs_complete",heirs_details) + ) + uid = f"bal-{str(self.bal_window.wallet)}" + summary = BalCalendar.ical_escape( + f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}".replace("$wallet_name",str(self.bal_window.wallet)) + ) + # The event date/time is the lower value between the will-settings + # locktime (here represented by ``alarm_end_dt``) and the valid will + # transaction with the lowest locktime. + min_tx_locktime = Will.get_min_locktime( + self.bal_window.willitems, None + ) + event_ts = BalCalendar.compute_event_timestamp( + alarm_end_dt.timestamp(), min_tx_locktime + ) + event_dt = datetime.fromtimestamp(event_ts) + event_time = BalCalendar.format_time(event_dt) + + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}", + "BEGIN:VEVENT", + f"UID:{uid}", + f"DTSTAMP:{now}", + f"DTSTART:{event_time}", + f"DTEND:{event_time}", + f"SUMMARY:{summary}", + f"DESCRIPTION:{event_description}", + ] + lines.extend(self.create_alarms(alarm_start_dt, alarm_end_dt)) + lines.extend([ + "END:VEVENT", + "END:VCALENDAR", + ]) + + lines = [s.rstrip("\r\n") for s in lines] + ics_content = "\r\n".join(lines) + "\r\n" + self.temp_path = BalCalendar.write_temp_ics(ics_content) + opened = BalCalendar.open_with_default_app( + self.bal_window.bal_plugin.CALENDAR_APP.get(), self.temp_path + ) + if opened: + _logger.info(f"File opened with default app: {self.temp_path}") + else: + export_meta_gui( + self.bal_window.window, f"will_event.ics",self.save_to_cwd + + ) + + + def save_to_cwd(self, filename="event.ics"): + """Copy the temporary .ics file to ``filename`` in the current dir. + + Overwrites the destination file if it already exists. Returns the + absolute path of the written file. + """ + target = os.path.abspath(filename) + _logger.debug(f"save_to_cwd {self.temp_path},{filename}") + with open(self.temp_path, "rb") as src, open(target, "wb") as dst: + dst.write(src.read()) + return target + + def on_locktime_change(self): + locktime = self.widgets["locktime"].get_value() + threshold = self.widgets["threshold"].get_value() + locktime = BalTimestamp(locktime) + threshold = BalTimestamp(threshold) + + min_locktime = min( + Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX), + locktime.to_timestamp(), + ) + td = threshold.to_date(min_locktime, True) + self.widgets["threshold"].alarm=td + self.bal_window.will_settings["real_threshold"]=td.timestamp() + try: + self.widgets["threshold"].editor.label.setText(td.strftime("%Y-%m-%d")) + except Exception as _e: + pass + + td = locktime.to_date() + alarm = BalTimestamp(min_locktime).to_date() + self.widgets["locktime"].alarm=alarm + self.bal_window.will_settings["real_locktime"]=td.timestamp() + try: + self.widgets["locktime"].editor.label.setText(td.strftime("%Y-%m-%d")) + except Exception as _e: + pass + + + +class PercAmountEdit(BTCAmountEdit): + def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=None): + super().__init__(decimal_point, is_int, parent, max_amount=max_amount) + + def numbify(self): + text = self.text().strip() + if text == "!": + self.shortcut.emit() + return + pos = self.cursorPosition() + chars = "0123456789%" + chars += DECIMAL_POINT + + s = "".join([i for i in text if i in chars]) + + if "%" in s: + self.is_perc = True + s = s.replace("%", "") + else: + self.is_perc = False + + if DECIMAL_POINT in s: + p = s.find(DECIMAL_POINT) + s = s.replace(DECIMAL_POINT, "") + s = s[:p] + DECIMAL_POINT + s[p : p + 8] + if self.is_perc: + s += "%" + + self.setText(s) + self.setModified(self.hasFocus()) + self.setCursorPosition(pos) + + def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]: + try: + text = text.replace(DECIMAL_POINT, ".") + text = text.replace("%", "") + return (Decimal)(text) + except Exception: + return None + + def _get_text_from_amount(self, amount): + out = super()._get_text_from_amount(amount) + if self.is_perc: + out += "%" + return out + + def paintEvent(self, event): + QLineEdit.paintEvent(self, event) + if self.base_unit: + panel = QStyleOptionFrame() + self.initStyleOption(panel) + textRect = self.style().subElementRect( + QStyle.SubElement.SE_LineEditContents, panel, self + ) + textRect.adjust(2, 0, -10, 0) + painter = QPainter(self) + painter.setPen(ColorScheme.GRAY.as_color()) + if len(self.text()) == 0: + painter.drawText( + textRect, + int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), + self.base_unit() + " or perc value", + ) + + + +class BalLineEdit(QLineEdit): + def __init__(self,variable): + QLineEdit.__init__(self) + self.setText(variable.get()) + def on_edit(): + variable.set(self.text()) + self.editingFinished.connect(on_edit) + + +class BalTextEdit(QTextEdit): + def __init__(self,variable): + QTextEdit.__init__(self) + self.setPlainText(variable.get()) + def on_edit(): + variable.set(self.toPlainText()) + self.textChanged.connect(on_edit) + + +class BalCheckBox(QCheckBox): + def __init__(self, variable, on_click=None): + QCheckBox.__init__(self) + self.setChecked(variable.get()) + self.on_click = on_click + + def on_check(v): + variable.set(v == 2) + #variable.get() + if self.on_click: + self.on_click() + + self.stateChanged.connect(on_check) + + + +class WillWidget(QWidget): + def __init__(self, father=None, parent=None): + super().__init__() + vlayout = QVBoxLayout() + self.setLayout(vlayout) + self.will = parent.bal_window.willitems + self._bal_parent = parent + for w in self.will: + if ( + self.will[w].get_status("REPLACED") + and self._bal_parent.bal_window.bal_plugin._hide_replaced + ): + continue + if ( + self.will[w].get_status("INVALIDATED") + and self._bal_parent.bal_window.bal_plugin._hide_invalidated + ): + continue + f = self.will[w].father + if father == f: + qwidget = QWidget() + # childWidget = QWidget() + hlayout = QHBoxLayout(qwidget) + qwidget.setLayout(hlayout) + vlayout.addWidget(qwidget) + detailw = QWidget() + detaillayout = QVBoxLayout() + detailw.setLayout(detaillayout) + + willpushbutton = QPushButton(w) + + willpushbutton.clicked.connect( + partial(self._bal_parent.bal_window.show_transaction, txid=w) + ) + detaillayout.addWidget(willpushbutton) + locktime = str(BalTimestamp(self.will[w].tx.locktime)) + creation = str(BalTimestamp(self.will[w].time)) + + def qlabel(title, value): + label = "" + _(str(title)) + f":\t{str(value)}" + return QLabel(label) + + detaillayout.addWidget(qlabel("Locktime", locktime)) + detaillayout.addWidget(qlabel("Creation Time", creation)) + try: + total_fees = ( + self.will[w].tx.input_value() - self.will[w].tx.output_value() + ) + except Exception: + total_fees = -1 + decoded_fees = total_fees + fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3) + fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)" + detaillayout.addWidget(qlabel("Transaction fees:", fees_str)) + detaillayout.addWidget(qlabel("Status:", self.will[w].status)) + detaillayout.addWidget(QLabel("")) + detaillayout.addWidget(QLabel("Heirs:")) + for heir in self.will[w].heirs: + if 'w!ll3x3c"' not in heir: + decoded_amount = Util.decode_amount( + self.will[w].heirs[heir][3], self._bal_parent.decimal_point + ) + detaillayout.addWidget( + qlabel( + heir, f"{decoded_amount} {self._bal_parent.base_unit_name}" + ) + ) + if self.will[w].we: + detaillayout.addWidget(QLabel("")) + detaillayout.addWidget(QLabel(_("Willexecutor: ``_setup_window``) for the same window, e.g. when + # Electrum restarts with the plugin already enabled. Calling + # ``init_menubar_tools`` twice would add the Heirs/Will tabs and the + # menu actions twice, producing the garbled/condensed menu entry. + self._menubar_initialized = False + self.bal_plugin.get_decimal_point = self.window.get_decimal_point + + if self.window.wallet: + self.wallet = self.window.wallet + if not self.will_settings: + self.will_settings = self.bal_plugin.WILL_SETTINGS.get() + Util.fix_will_settings_tx_fees(self.will_settings) + self.heirs = Heirs(self.wallet) + + self.heirs_tab = self.create_heirs_tab() + self.will_tab = self.create_will_tab() + self.heirs_tab.wallet = self.wallet + self.will_tab.wallet = self.wallet + + def init_menubar_tools(self, tools_menu): + # Idempotent: only wire the tabs + menu actions once per window. + # A second call (e.g. init_menubar hook *and* the hot-init path both + # firing) would otherwise duplicate the Heirs/Will tabs and the + # Will-Executors / toggle actions, which Qt renders as a broken, + # condensed menu entry under the Electrum logo. + if self._menubar_initialized: + _logger.info("init_menubar_tools: already initialised, skipping") + return + self._menubar_initialized = True + self.tools_menu = tools_menu + + def add_optional_tab(tabs, tab, icon, description): + tab.tab_icon = icon + tab.tab_description = description + tab.tab_pos = len(tabs) + if tab.is_shown_cv.get(): + tabs.addTab(tab, icon, description.replace("&", "")) + + def add_toggle_action(tab): + is_shown = tab.is_shown_cv.get() + tab.menu_action = self.window.view_menu.addAction( + tab.tab_description, lambda: self.window.toggle_tab(tab) + ) + tab.menu_action.setCheckable(True) + tab.menu_action.setChecked(is_shown) + + add_optional_tab( + self.window.tabs, + self.heirs_tab, + read_QIcon_from_bytes(self.bal_plugin.read_file("icons/heir.png")), + _("&Heirs"), + ) + add_optional_tab( + self.window.tabs, + self.will_tab, + read_QIcon_from_bytes(self.bal_plugin.read_file("icons/will.png")), + _("&Will"), + ) + tools_menu.addSeparator() + self.tools_menu.willexecutors_action = tools_menu.addAction( + _("&Will-Executors"), self.show_willexecutor_dialog + ) + self.window.view_menu.addSeparator() + add_toggle_action(self.heirs_tab) + add_toggle_action(self.will_tab) + + def load_willitems(self): + self.willitems = {} + for wid, w in self.will.items(): + self.willitems[wid] = WillItem(w, wallet=self.wallet) + if self.willitems: + self.will_list_widget.will = self.willitems + self.will_list_widget.update_will(self.willitems) + self.will_tab.update() + + def save_willitems(self): + keys = list(self.will.keys()) + for k in keys: + del self.will[k] + for wid, w in self.willitems.items(): + self.will[wid] = w.to_dict() + + def init_will(self): + _logger.info("********************init_____will____________**********") + if not self.willexecutors: + self.willexecutors = Willexecutors.get_willexecutors( + self.bal_plugin, update=False, bal_window=self + ) + if not self.heirs: + self.heirs = Heirs._validate(Heirs(self.wallet)) + self.heirs_tab.update() + if not self.will: + self.will = self.wallet.db.get_dict("will") + Util.fix_will_tx_fees(self.will) + if self.will: + self.willitems = {} + try: + self.load_willitems() + except Exception: + self.disable_plugin = True + self.show_warning( + _("Please restart Electrum to activate the BAL plugin"), + title=_("Success"), + ) + self.close_wallet() + return + + # if not self.will_settings: + # self.will_settings = self.wallet.db.get_dict("will_settings") + # Util.fix_will_settings_tx_fees(self.will_settings) + + # _logger.info("will_settings: {}".format(self.will_settings)) + # if not self.will_settings: + # Util.copy(self.will_settings, self.bal_plugin.default_will_settings()) + # _logger.debug("not_will_settings {}".format(self.will_settings)) + # self.bal_plugin.validate_will_settings(self.will_settings) + # self.heir_list_widget.update_will_settings() + # self.heir_list_widget.update() + + def init_wizard(self): + wizard_dialog = BalWizardDialog(self) + wizard_dialog.exec() + + def show_willexecutor_dialog(self): + self.willexecutor_dialog = WillExecutorDialog(self) + # Keep it in front of Electrum (window-modal) instead of letting it + # fall behind the main window. + show_on_top(self.willexecutor_dialog) + + def create_heirs_tab(self): + if not self.heirs: + self.heirs = Heirs(self.wallet) + self.heir_list_widget = HeirListWidget(self, self.window) + tab = self.window.create_list_tab(self.heir_list_widget) + tab.is_shown_cv = shown_cv(False) + return tab + + def create_will_tab(self): + self.will_list_widget = PreviewList(self, self.window, None) + tab = self.window.create_list_tab(self.will_list_widget) + tab.is_shown_cv = shown_cv(True) + return tab + + def new_heir_dialog(self, heir_key=None): + heir = self.heirs.get(heir_key) + title = "New heir" + if heir: + title = f"Edit: {heir_key}" + + d = BalDialog( + self.window, self.bal_plugin, self.bal_plugin.get_window_title(_(title)) + ) + + vbox = QVBoxLayout(d) + grid = QGridLayout() + + heir_name = QLineEdit() + heir_name.setFixedWidth(32 * char_width_in_lineedit()) + heir_address = QLineEdit() + heir_address.setFixedWidth(32 * char_width_in_lineedit()) + heir_amount = PercAmountEdit(self.window.get_decimal_point) + + if heir: + heir_name.setText(str(heir_key)) + heir_address.setText(str(heir[0])) + heir_amount.setText( + str(Util.decode_amount(heir[1], self.window.get_decimal_point())) + ) + self.heir_locktime = LockTimeWidget(self, self.window, heir[2]) + + # heir_is_xpub = QCheckBox() + + new_heir_button = QPushButton(_("Add another heir")) + self.add_another_heir = False + + def new_heir(): + self.add_another_heir = True + d.accept() + + new_heir_button.clicked.connect(new_heir) + new_heir_button.setDefault(True) + + grid.addWidget(QLabel(_("Name")), 1, 0) + grid.addWidget(heir_name, 1, 1) + grid.addWidget(HelpButton(_("Unique name or description about heir")), 1, 2) + + grid.addWidget(QLabel(_("Address")), 2, 0) + grid.addWidget(heir_address, 2, 1) + grid.addWidget(HelpButton(_("heir bitcoin address")), 2, 2) + + grid.addWidget(QLabel(_("Amount")), 3, 0) + grid.addWidget(heir_amount, 3, 1) + grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2) + + locktime_label = QLabel(_("Locktime")) + enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get() + if enable_multiverse: + grid.addWidget(locktime_label, 4, 0) + grid.addWidget(self.heir_locktime, 4, 1) + grid.addWidget(HelpButton(_("locktime")), 4, 2) + + vbox.addLayout(grid) + buttons = [CancelButton(d), OkButton(d)] + if not heir: + buttons.append(new_heir_button) + vbox.addLayout(Buttons(*buttons)) + while d.exec(): + # TODO SAVE HEIR + heir = [ + heir_name.text(), + heir_address.text(), + Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()), + str(self.will_settings["locktime"]), + ] + try: + self.set_heir(heir) + if self.add_another_heir: + self.new_heir_dialog() + break + except Exception as e: + self.show_error(str(e)) + + def set_heir(self, heir): + heir = list(heir) + if not self.bal_plugin.ENABLE_MULTIVERSE.get(): + heir[3] = self.will_settings["locktime"] + + h = Heirs.validate_heir(heir[0], heir[1:]) + self.heirs[heir[0]] = h + self.heir_list_widget.update() + return True + + def delete_heirs(self, heirs): + for heir in heirs: + try: + del self.heirs[heir] + except Exception as e: + _logger.debug(f"error deleting heir: {heir} {e}") + pass + self.heirs.save() + self.heir_list_widget.update() + return True + + def import_heirs(self): + import_meta_gui( + self.window, + _("heirs"), + self.heirs.import_file, + self.heir_list_widget.update, + ) + + def export_heirs(self): + export_meta_gui(self.window, "heirs.json", self.heirs.export_file) + + def prepare_will(self, ignore_duplicate=False, keep_original=False): + will = self.build_inheritance_transaction( + ignore_duplicate=ignore_duplicate, keep_original=keep_original + ) + return will + + def delete_not_valid(self, txid, s_utxo): + raise NotImplementedError() + + def update_will(self, will): + Will.update_will(self.willitems, will) + self.willitems.update(will) + Will.normalize_will(self.willitems, self.wallet) + + def build_will(self, ignore_duplicate=True, keep_original=True): + _logger.debug("building will...") + will = {} + # willtodelete = [] + # willtoappend = {} + if not self.will_settings: + self.will_settings = self.bal_plugin.WILL_SETTINGS.get() + Util.fix_will_settings_tx_fees(self.will_settings) + try: + self.willexecutors = Willexecutors.get_willexecutors( + self.bal_plugin, update=False, bal_window=self + ) + if not self.no_willexecutor: + + f = False + for _u, w in self.willexecutors.items(): + if Willexecutors.is_selected(w): + f = True + if not f: + _logger.error("No Will-Executor or backup transaction selected") + raise NoWillExecutorNotPresent( + "No Will-Executor or backup transaction selected" + ) + txs = self.heirs.get_transactions( + self.bal_plugin, + self.window.wallet, + self.will_settings["baltx_fees"], + None, + self.date_to_check, + ) + + _logger.info(f"txs built: {txs}") + creation_time = time.time() + if txs: + for txid in txs: + # txtodelete = [] + _break = False + tx = {} + tx["tx"] = txs[txid] + tx["my_locktime"] = txs[txid].my_locktime + tx["heirsvalue"] = txs[txid].heirsvalue + tx["description"] = txs[txid].description + tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor) + tx["status"] = _("New") + tx["baltx_fees"] = txs[txid].tx_fees + tx["time"] = creation_time + tx["heirs"] = copy.deepcopy(txs[txid].heirs) + tx["txchildren"] = [] + will[txid] = WillItem(tx, _id=txid, wallet=self.wallet) + self.update_will(will) + else: + _logger.info("No transactions was built") + _logger.info(f"will-settings: {self.will_settings}") + _logger.info(f"date_to_check:{self.date_to_check}") + _logger.info(f"heirs: {self.heirs}") + return {} + except Exception as e: + _logger.info(f"Exception build_will: {e}") + raise e + pass + return self.willitems + + def check_will(self): + return Will.is_will_valid( + self.willitems, + self.date_to_check, + self.will_settings["baltx_fees"], + self.window.wallet.get_utxos(), + heirs=self.heirs, + willexecutors=self.willexecutors, + self_willexecutor=self.no_willexecutor, + wallet=self.wallet, + callback_not_valid_tx=self.delete_not_valid, + ) + + def show_message(self, text): + self.window.show_message(text) + + def show_warning(self, text, parent=None): + self.window.show_warning(text, parent=None) + + def show_error(self, text): + self.window.show_error(text) + + def show_critical(self, text): + self.window.show_critical(text) + + def update_combo_setting_widgets( + self, + new_value, + field, + update_all=False, + update_will_dialog=False, + update_heirs_dialog=False, + ): + if (update_all or update_will_dialog) and hasattr(self,'will_list_widget'): + self.update_widget_combo(self.will_list_widget,field,new_value) + if update_all or update_heirs_dialog and hasattr(self,'heir_list_widget'): + self.update_widget_combo(self.heir_list_widget,field,new_value) + + + def update_widget_combo(self,widget,field,value): + try: + widget.will_settings_widget.widgets[field].set_index(value) + except Exception as _e: + pass + def update_widget_value(self, widget, field, value): + try: + widget.will_settings_widget.widgets[field].set_value(value) + except Exception as _e: + pass + + def update_setting_widgets( + self, + new_value, + field, + update_all=False, + update_will_dialog=False, + update_heirs_dialog=False, + ): + if update_all or update_heirs_dialog: + self.update_widget_value(self.heir_list_widget, field, new_value) + if update_all or update_will_dialog: + self.update_widget_value(self.will_list_widget, field, new_value) + self.will_settings[field] = new_value + self.bal_plugin.WILL_SETTINGS.set(self.will_settings) + + def init_heirs_to_locktime(self, multiverse=False): + if multiverse: + return + # Coerce the locktime to a plain serializable scalar: will_settings is + # read from Electrum's config and a non-primitive value here would end + # up inside the heirs dict and break json_db persistence (this was one + # path to the "cannot pickle '_thread.RLock' object" error). + locktime = self.will_settings["locktime"] + if not isinstance(locktime, (int, float, str)): + locktime = str(locktime) + # Iterate over a snapshot of the keys: assigning to self.heirs[...] + # triggers Heirs.__setitem__ -> save(), which mutates the mapping while + # we iterate it. Building the new values first and applying them after + # the loop avoids "dict changed size during iteration" and the repeated + # save() on every heir. + updates = { + heir: [self.heirs[heir][0], self.heirs[heir][1], locktime] + for heir in list(self.heirs) + } + for heir, value in updates.items(): + self.heirs[heir] = value + + def init_class_variables(self): + if not self.heirs: + raise NoHeirsException(_("Heirs are not defined")) + if not self.will_settings: + self.will_settings = self.bal_plugin.WILL_SETTINGS.get() + Util.fix_will_settings_tx_fees(self.will_settings) + try: + self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp() + self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get() + self.willexecutors = Willexecutors.get_willexecutors( + self.bal_plugin, update=True, bal_window=self, task=False + ) + if self.date_to_check < datetime.now().timestamp(): + raise CheckAliveError(self.date_to_check) + + self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get()) + + except Exception as e: + log_error(e ) + _logger.error(f"init_class_variables: {e}") + + raise e + + def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True): + try: + if self.disable_plugin: + _logger.info("plugin is disabled") + return + if not self.heirs: + _logger.warning("not heirs {}".format(self.heirs)) + return + try: + self.init_class_variables() + Will.check_amounts( + self.heirs, + self.willexecutors, + self.window.wallet.get_utxos(), + self.date_to_check, + self.window.wallet.dust_threshold(), + ) + except AmountException as e: + self.show_warning( + _( + f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}" + ) + ) + except CheckAliveError: + self.show_error( + _( + "CheckAlive is in the past please update it to a date in the future but less than locktime" + ) + ) + return + locktime = Util.parse_locktime_string(self.will_settings["locktime"]) + if locktime < self.date_to_check: + self.show_error(_("locktime is lower than threshold")) + return + if not self.no_willexecutor: + f = False + for _k, we in self.willexecutors.items(): + if Willexecutors.is_selected(we): + f = True + if not f: + self.show_error( + _(" no backup transaction or willexecutor selected") + ) + return + + try: + self.check_will() + except WillExpiredException: + self.invalidate_will() + return + except NoHeirsException: + return + except WillPostponedException as e: + # The will was already signed/sent and is being postponed. + # We do NOT rebuild automatically: the user must first sign and + # broadcast the invalidation tx (so the old, earlier-locktime tx + # can never be used by a will-executor), then press "Prepare" + # again + # to create the new postponed inheritance. + _logger.info(f"will postponed: {e}") + self.show_message( + _( + "This inheritance was already signed/sent to " + "will-executors and you are postponing it.\n\n" + "The previously committed coins must be invalidated " + "on-chain FIRST, otherwise a will-executor could " + "broadcast the old (earlier) transaction and execute " + "the inheritance too early.\n\n" + "Please sign and broadcast the invalidation transaction " + "now, then press 'Prepare' again to create the new " + "(postponed) inheritance." + ) + ) + self.invalidate_will() + return + except NotCompleteWillException as e: + _logger.info("{}:{}".format(type(e), e)) + message = False + if isinstance(e, HeirChangeException): + message = "Heirs changed:" + elif isinstance(e, WillExecutorNotPresent): + message = "Will-Executor not present:" + elif isinstance(e, WillexecutorChangeException): + message = "Will-Executor changed" + elif isinstance(e, TxFeesChangedException): + message = "Txfees are changed" + elif isinstance(e, HeirNotFoundException): + message = "Heir not found" + + if message: + self.show_message( + f"{_(message)}:\n {e}\n{_('will have to be built')}" + ) + + _logger.info("build will") + self.build_will(ignore_duplicate, keep_original) + + # Track whether the rebuild produced a coherent, ready-to-sign + # will, so we can guide the user through the remaining manual + # steps (Sign + Broadcast) afterwards. + rebuilt_ok = False + try: + self.check_will() + for wid, _w in self.willitems.items(): + self.wallet.set_label(wid, "BAL Transaction") + rebuilt_ok = True + except WillExpiredException as e: + self.invalidate_will() + except NotCompleteWillException as e: + self.show_error( + "Error:{}\n {}".format( + str(e), + _("Please, check your heirs, locktime and threshold!"), + ) + ) + + self.window.history_list.update() + self.window.utxo_list.update() + + # Guide the user: the inheritance was just (re)built and is now + # in the "New" state, so it must be SIGNED and then BROADCAST + # again -- two manual steps the user has to perform. Without + # this hint the user is left with a freshly rebuilt will and no + # indication that it still needs to be signed and re-sent to the + # will-executors. + if rebuilt_ok: + if self.no_willexecutor: + next_steps = _( + "Your inheritance has been rebuilt and now needs " + "to be signed again.\n\n" + "Next step (manual):\n" + " 1. Press 'Sign' to sign the new transaction." + ) + else: + next_steps = _( + "Your inheritance has been rebuilt and now needs " + "to be signed and re-sent to the will-executors.\n\n" + "Next steps (manual):\n" + " 1. Press 'Sign' to sign the new transaction.\n" + " 2. Press 'Broadcast' to send it to the " + "will-executors." + ) + self.show_message(next_steps) + self.update_all() + return self.willitems + except Exception as e: + raise e + + def show_transaction_real( + self, + tx: Transaction, + *, + parent: "ElectrumWindow", + prompt_if_unsaved: bool = False, + external_keypairs: Mapping[bytes, bytes] = None, + payment_identifier: "PaymentIdentifier" = None, + ): + try: + d = TxDialog( + tx, + parent=parent, + prompt_if_unsaved=prompt_if_unsaved, + external_keypairs=external_keypairs, + # payment_identifier=payment_identifier, + ) + d.setWindowIcon( + read_QIcon_from_bytes(self.bal_plugin.read_file("icons/bal16x16.png")) + ) + except SerializationError as e: + _logger.error("unable to deserialize the transaction") + parent.show_critical( + _("Electrum was unable to deserialize the transaction:") + "\n" + str(e) + ) + else: + # Electrum's own TxDialog: keep it in front of the main window. + show_on_top(d, modal_to_window=False) + return d + + def show_transaction(self, tx=None, txid=None, parent=None): + if not parent: + parent = self.window + if txid is not None and txid in self.willitems: + tx = self.willitems[txid].tx + if not tx: + raise Exception(_("no tx")) + return self.show_transaction_real(tx, parent=parent) + + def invalidate_will(self): + def on_success(result): + if result: + self.show_message( + _( + "Please sign and broadcast this transaction to invalidate current will" + ) + ) + self.wallet.set_label(result.txid(), "BAL Invalidate") + self.show_transaction(result) + else: + self.show_message(_("No transactions to invalidate")) + + def on_failure(exec_info): + log_error(exec_info, self.bal_window) + + fee_per_byte = self.will_settings.get("baltx_fees", 1) + task = partial(Will.invalidate_will, self.willitems, self.wallet, fee_per_byte) + msg = _("Calculating Transactions") + self.waiting_dialog = BalWaitingDialog( + self, msg, task, on_success, on_failure, exe=False + ) + self.waiting_dialog.exe() + + def sign_transactions(self, password): + try: + txs = {} + signed = None + tosign = None + + def get_message(): + msg = "" + if signed: + msg = _(f"signed: {signed}\n") + return msg + _(f"signing: {tosign}") + + for txid in Will.only_valid(self.willitems): + wi = self.willitems[txid] + tx = copy.deepcopy(wi.tx) + if wi.get_status("COMPLETE"): + txs[txid] = tx + continue + tosign = txid + try: + self.waiting_dialog.update(get_message()) + except Exception: + pass + for txin in tx.inputs(): + prevout = txin.prevout.to_json() + if prevout[0] in self.willitems: + change = self.willitems[prevout[0]].tx.outputs()[prevout[1]] + txin._trusted_value_sats = change.value + try: + txin.script_descriptor = change.script_descriptor + except Exception: + pass + txin.is_mine = True + txin._TxInput__address = change.address + txin._TxInput__scriptpubkey = change.scriptpubkey + txin._TxInput__value_sats = change.value + + self.wallet.sign_transaction(tx, password, ignore_warnings=True) + signed = tosign + # is_complete = False + if tx.is_complete(): + # is_complete = True + wi.set_status("COMPLETE", True) + txs[txid] = tx + except Exception: + return None + return txs + + def get_wallet_password(self, message=None, parent=None): + parent = self.window if not parent else parent + password = None + if self.wallet.has_keystore_encryption(): + password = self.bal_plugin.password_dialog(parent=parent, msg=message) + if password is None: + return False + try: + self.wallet.check_password(password) + except Exception as e: + self.show_error(str(e)) + password = self.get_wallet_password(message) + return password + + def on_close(self): + # Wallet is closing: run the closing "build will" task and tear down + # the plugin's tabs/menu. Each step is isolated so that one failure + # does not leave the GUI half-initialised (which previously forced the + # user to restart Electrum). Errors are logged instead of silently + # swallowed. + if self.disable_plugin: + return + + # 1) Business logic: build/save the will on close (unchanged behaviour). + try: + close_window = BalBuildWillDialog(self) + close_window.build_will_task() + self.save_willitems() + except Exception as e: + _logger.error(f"on_close: build/save will failed: {e}") + + # 2) GUI teardown - each action guarded independently. + def _safe(desc, fn): + try: + fn() + except Exception as e: + _logger.error(f"on_close: {desc} failed: {e}") + + _safe("close heirs tab", lambda: self.heirs_tab.close()) + _safe("close will tab", lambda: self.will_tab.close()) + _safe( + "remove willexecutors menu action", + lambda: self.tools_menu.removeAction( + self.tools_menu.willexecutors_action + ), + ) + _safe("toggle heirs tab off", lambda: self.window.toggle_tab(self.heirs_tab)) + _safe("toggle will tab off", lambda: self.window.toggle_tab(self.will_tab)) + _safe("refresh tabs", lambda: self.window.tabs.update()) + + # 3) Reset in-memory state so re-enabling/re-opening starts clean. + self.willitems = {} + self.will = {} + self.heirs = {} + self.willexecutors = {} + self.disable_plugin = True + self.ok = False + # The tabs/menu actions were removed above; allow init_menubar_tools to + # re-wire them if this same window is reused for another wallet. + self._menubar_initialized = False + + def ask_password_and_sign_transactions(self, callback=None): + def on_success(txs): + if txs: + for txid, tx in txs.items(): + self.willitems[txid].tx = copy.deepcopy(tx) + self.will[txid] = self.willitems[txid].to_dict() + try: + self.will_list_widget.update() + except Exception: + pass + if callback: + try: + callback() + except Exception as e: + raise e + + def on_failure(exec_info): + log_error(exec_info, self.bal_window) + + password = self.get_wallet_password() + task = partial(self.sign_transactions, password) + msg = _("Signing transactions...") + self.waiting_dialog = BalWaitingDialog( + self, msg, task, on_success, on_failure, exe=False + ) + self.waiting_dialog.exe() + + def broadcast_transactions(self, force=False): + def on_success(sulcess): + self.will_list_widget.update() + if sulcess: + _logger.info("error, some transaction was not sent") + self.show_warning(_("Some transaction was not broadcasted")) + return + _logger.debug("OK, sulcess transaction was sent") + self.show_message( + _("All transactions are broadcasted to respective Will-Executors") + ) + + def on_failure(exec_info): + log_error(exec_info, self.bal_window) + # a,b,c = err + # _logger.error(f"fail to broadcast transactions:{err}") + # _logger.error(f"error: {b}") + # _logger.error("traceback ") + # tb = c + # while tb is not None: + # frame = tb.tb_frame + # _logger.error("file:", frame.f_code.co_filename) + # _logger.error("name:", frame.f_code.co_name) + # _logger.error("line:", tb.tb_lineno) + # _logger.error("lasti:", tb.tb_lasti) + # tb = tb.tb_next + + task = partial(self.push_transactions_to_willexecutors, force) + msg = _("Selecting Will-Executors") + self.waiting_dialog = BalWaitingDialog( + self, msg, task, on_success, on_failure, exe=False + ) + self.waiting_dialog.exe() + + def push_transactions_to_willexecutors(self, force=False): + willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems) + + def getMsg(willexecutors): + msg = "Broadcasting Transactions to Will-Executors:\n" + for url in willexecutors: + msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n" + return msg + + # Initialise statuses + show the list immediately. + for url in willexecutors: + willexecutors[url].setdefault("broadcast_status", _("waiting...")) + try: + self.waiting_dialog.update(getMsg(willexecutors)) + except Exception: + pass + + error = {"flag": False} + already_present = [] + + def on_each(url, willexecutor, ok, exc): + # Runs from a worker thread. We only do book-keeping + a thread-safe + # signal-based UI update here; the heavier "already present" check + # path (which itself does network I/O) is handled below in the main + # task thread to keep the original sequential behaviour for it. + if isinstance(exc, Willexecutors.AlreadyPresentException): + already_present.append(url) + willexecutor["broadcast_status"] = _("checking...") + elif ok: + for wid in willexecutor.get("txsids", []): + self.willitems[wid].set_status("PUSHED", True) + willexecutor["broadcast_status"] = _("Success") + else: + for wid in willexecutor.get("txsids", []): + self.willitems[wid].set_status("PUSH_FAIL", True) + error["flag"] = True + willexecutor["broadcast_status"] = _("Failed") + willexecutor.pop("txs", None) + try: + self.waiting_dialog.update(getMsg(willexecutors)) + except Exception: + pass + + if self.waiting_dialog._stopping: + return + # Push to all servers in parallel (each server keeps its own retry + # behaviour, but a slow/dead server no longer blocks the others). + Willexecutors.push_transactions_parallel(willexecutors, on_each=on_each) + + # Handle the "already present" servers: verify each stored tx. This + # keeps the exact original check logic, just executed after the parallel + # push has identified which servers need it. + for url in already_present: + willexecutor = willexecutors[url] + for wid in willexecutor.get("txsids", []): + if self.waiting_dialog._stopping: + return + self.waiting_dialog.update( + "checking {} - {} : {}".format( + self.willitems[wid].we["url"], wid, "Waiting" + ) + ) + w = self.willitems[wid] + w.set_check_willexecutor( + Willexecutors.check_transaction(wid, w.we["url"]) + ) + self.waiting_dialog.update( + "checked {} - {} : {}".format( + self.willitems[wid].we["url"], + wid, + self.willitems[wid].get_status("CHECKED"), + ) + ) + + if error["flag"]: + return True + + def export_json_file(self, path): + for wid in self.willitems: + self.willitems[wid].set_status("EXPORTED", True) + self.will[wid] = self.willitems[wid].to_dict() + write_json_file(path, self.will) + + def export_will(self): + try: + export_meta_gui(self.window, "will.json", self.export_json_file) + except Exception as e: + self.show_error(str(e)) + raise e + + def import_will(self): + def sulcess(): + self.will_list_widget.update_will(self.willitems) + + import_meta_gui(self.window, _("will"), self.import_json_file, sulcess) + + def import_json_file(self, path): + try: + data = read_json_file(path) + willitems = {} + for k, v in data.items(): + data[k]["tx"] = tx_from_any(v["tx"]) + willitems[k] = WillItem(data[k], _id=k) + self.update_will(willitems) + except Exception as e: + raise e + # raise FileImportFailed(_("Invalid will file")) + + def check_transactions_task(self, will): + start = time.time() + # Servers are now contacted in parallel (see + # Willexecutors.check_transactions_parallel) with a fast-fail timeout and + # a global deadline, so a single slow/dead will-executor no longer + # freezes the "checking transaction" dialog for minutes. The dialog + # shows live progress plus an elapsed-time counter (Xs / DEADLINEs). + targets = [(wid, w.we["url"]) for wid, w in will.items() if w.we] + total = len(targets) + deadline = Willexecutors.CHECK_GLOBAL_DEADLINE + done = {"count": 0} + + def _status_line(): + return "{} {}/{} ({}s / {}s)".format( + _("Checking transactions"), done["count"], total, + min(int(time.time() - start), deadline), deadline, + ) + + def on_each(wid, url, res, exc): + # Reuse the original per-item logic: set_check_willexecutor handles + # both a real response and a None/failure (-> CHECK_FAIL). + try: + will[wid].set_check_willexecutor(res) + except Exception as e: + _logger.error(f"check on_each error for {wid}: {e}") + done["count"] += 1 + self.waiting_dialog.update(_status_line()) + + def on_timeout(wid, url): + # The global deadline elapsed before this server answered: mark the + # item as failed (None response) so the user can retry later. + try: + will[wid].set_check_willexecutor(None) + except Exception as e: + _logger.error(f"check on_timeout error for {wid}: {e}") + + def on_tick(): + if getattr(self.waiting_dialog, "_stopping", False): + return + self.waiting_dialog.update(_status_line()) + + if total: + self.waiting_dialog.update(_status_line()) + Willexecutors.check_transactions_parallel( + targets, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick + ) + + if time.time() - start < 3: + time.sleep(3 - (time.time() - start)) + + def check_transactions(self, will): + def on_success(result): + if hasattr(self,"waiting_dialog"): + del self.waiting_dialog + self.update_all() + pass + + def on_failure(exec_info): + log_error(exec_info, self) + # _logger.error(f"error checking transactions {e}") + # pass + + task = partial(self.check_transactions_task, will) + msg = _("Check Transaction") + self.waiting_dialog = BalWaitingDialog( + self, msg, task, on_success, on_failure, exe=False + ) + self.waiting_dialog.exe() + + def update_willexecutor_list_widget(self, parent, willexecutors): + try: + parent.willexecutors_list.update(willexecutors) + parent.will_executor_list_widget.update() + except Exception as e: + _logger.error(f"impossible to update will_executor_list_widget {e}") + self.will_executors.update() + + def fetch_will_executors_list(self, old_willexecutors): + """Download the will-executor list (runs inside the TaskThread worker). + + Tries the configured server first, then the original hardcoded endpoint, + so a stale/bad config value cannot break the download. Detailed + per-attempt diagnostics are written to the Electrum log only; the user + sees a simple message. No business logic in ``bal.core`` is changed. + + Returns the downloaded dict (empty ``{}`` on failure). + """ + chainname = BalPlugin.chainname + configured = self.bal_plugin.WELIST_SERVER.get() + candidates = [] + for base in (configured, "https://welist.bitcoin-after.life/"): + if not base: + continue + base = base if base.endswith("/") else base + "/" + url = f"{base}data/{chainname}?page=0&limit=100" + if url not in candidates: + candidates.append(url) + + result = {} + net = Network.get_instance() + _logger.info(f"fetch_will_executors_list: network present = {net is not None}") + for url in candidates: + _logger.info(f"fetch_will_executors_list: trying {url}") + try: + # Fast-fail with a couple of short retries instead of the + # default 10x/3s storm: if the user's connection is flaky we + # want to fall back to the next URL (and then show the simple + # error message) quickly, not freeze for minutes. + resp = Willexecutors.send_request( + "get", url, timeout=10, max_retries=1, retry_sleep=1, + ) + _logger.info( + f"fetch_will_executors_list: resp type={type(resp).__name__} " + f"len={len(resp) if hasattr(resp, '__len__') else 'n/a'}" + ) + if resp: + result = resp + for w in result: + if w not in ("status", "url"): + Willexecutors.initialize_willexecutor( + result[w], w, None, + old_willexecutors.get(w, None), + ) + break + _logger.warning(f"fetch_will_executors_list: {url} -> empty response") + except Exception as e: + _logger.error( + f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}" + ) + return result + + # Simple, user-facing message shown when the download fails for any reason + # (the technical cause is in the Electrum log). + DOWNLOAD_FAILED_MESSAGE = ( + "Could not download the will-executors list.\n\n" + "This is usually caused by your internet connection or a firewall, " + "not by the plugin. Please check your connection (a VPN often helps) " + "and try again." + ) + + def download_list(self, willexecutors, fn_on_success, fn_on_failure=None): + if fn_on_failure is None: + fn_on_failure = log_error + + base_msg = _("Downloading will-executors list...") + download_start = time.time() + # Upper bound shown to the user. fetch_will_executors_list tries up to + # two endpoints, each with timeout=10 and one retry (~21s worst case), + # so ~45s is a realistic maximum. Showing "Xs / 45s" tells the user how + # long they may have to wait instead of an open-ended counter. + download_deadline = 45 + + def task(): + # Heartbeat: show an elapsed-seconds counter (with the max wait made + # explicit) while the (blocking) download runs, so the user sees time + # advancing instead of a seemingly frozen dialog on a slow link. + stop_heartbeat = threading.Event() + + def _heartbeat(): + while not stop_heartbeat.wait(1.0): + if getattr(self.waiting_dialog, "_stopping", False): + return + try: + self.waiting_dialog.update( + "{} ({}s / {}s)".format( + base_msg, + min(int(time.time() - download_start), + download_deadline), + download_deadline, + ) + ) + except Exception: + return + + hb = threading.Thread(target=_heartbeat, name="bal-dl-hb", + daemon=True) + hb.start() + try: + return self.fetch_will_executors_list(willexecutors) + finally: + stop_heartbeat.set() + + def on_success(result): + if result: + self.willexecutors.update(result) + fn_on_success(result) + else: + self.show_warning(_(self.DOWNLOAD_FAILED_MESSAGE)) + + def on_failure(exc_info): + _logger.error(f"download_list failed: {exc_info}") + self.show_warning(_(self.DOWNLOAD_FAILED_MESSAGE)) + + self.waiting_dialog = BalWaitingDialog( + self, base_msg, task, on_success, on_failure, exe=False + ) + self.waiting_dialog.exe() + + def ping_willexecutors_task(self, wes): + _logger.info("ping willexecutots task") + # Track per-url state for the live status text. Servers are contacted + # in parallel (see Willexecutors.ping_servers_parallel), so a single + # unreachable server no longer blocks all the others: the whole batch + # now takes about as long as the slowest server instead of the sum of + # every server's (possibly timing-out) request. + pinged = set() + failed = set() + total = len(wes) + ping_start = time.time() + + ping_deadline = Willexecutors.PUSH_GLOBAL_DEADLINE + + def get_title(): + # Header shows progress + an elapsed-seconds counter with the max + # wait made explicit (e.g. "3s / 30s"), so the user sees time + # advancing and knows how long it may take, instead of a seemingly + # frozen dialog. + answered = len(pinged) + len(failed) + msg = _("Ping Will-Executors:") + msg += " {}/{} ({}s / {}s)".format( + answered, total, + min(int(time.time() - ping_start), ping_deadline), + ping_deadline, + ) + msg += "\n\n" + for url in wes: + urlstr = "{:<50}: ".format(url[:50]) + if url in pinged: + urlstr += _("Ok") + elif url in failed: + urlstr += _("Ko") + else: + urlstr += _("waiting...") + urlstr += "\n" + msg += urlstr + return msg + + def on_each(url, we, ok): + if ok: + pinged.add(url) + else: + failed.add(url) + try: + self.waiting_dialog.update(get_title()) + except Exception: + pass + + # Show the initial "waiting..." list immediately. + try: + self.waiting_dialog.update(get_title()) + except Exception: + pass + + # Refresh the elapsed-seconds counter while the (blocking) parallel ping + # runs. The tick is driven from THIS thread by ping_servers_parallel, + # the same thread that drives on_each, so the dialog repaint is reliable. + def on_tick(): + if getattr(self.waiting_dialog, "_stopping", False): + return + try: + self.waiting_dialog.update(get_title()) + except Exception: + pass + + Willexecutors.ping_servers_parallel(wes, on_each=on_each, on_tick=on_tick) + + def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None): + def on_success(result): + fn_on_success(result) + + def on_failure(exec_info): + fn_on_failure(exec_info) + + if not fn_on_failure: + fn_on_failure = log_error + _logger.info("ping willexecutors") + task = partial(self.ping_willexecutors_task, wes) + msg = _("Ping Will-Executors") + self.waiting_dialog = BalWaitingDialog( + self, msg, task, on_success, on_failure, exe=False + ) + self.waiting_dialog.exe() + + def preview_modal_dialog(self): + self.dw = WillDetailDialog(self) + # This dialog is meant to be modal (per its name); show it on top so it + # cannot disappear behind the Electrum window. + show_on_top(self.dw) + + def update_all(self): + try: + # Re-sync the cached "hide invalidated/replaced" flags from the + # persisted config before refreshing the list. The Settings dialog + # checkboxes write the config directly (without touching the cached + # flags), so without this the list would keep filtering with the old + # value and the invalidated/replaced rows would not appear/disappear + # until Electrum was restarted. + self.bal_plugin.sync_hide_filters() + Will.add_willtree(self.willitems) + all_utxos = self.wallet.get_utxos() + utxos_list = Will.utxos_strs(all_utxos) + Will.check_invalidated(self.willitems, utxos_list, self.wallet) + + self.will_list_widget.update_will(self.willitems) + self.heirs_tab.update() + self.will_tab.update() + self.will_list_widget.update() + except Exception as e: + _logger.error(f"error while updating window: {e}") + + diff --git a/bal/gui/qt/window_utils.py b/bal/gui/qt/window_utils.py new file mode 100644 index 0000000..6817ca4 --- /dev/null +++ b/bal/gui/qt/window_utils.py @@ -0,0 +1,119 @@ +""" +bal.gui.qt.window_utils +======================= + +Centralized window/dialog presentation helpers. + +The original plugin opened dialogs inconsistently: some with ``exec()`` +(modal, stays on top) and some with ``show()`` (modeless, can fall *behind* +the main Electrum window). It also relied on a per-instance ``self.parent`` +attribute that shadows :meth:`QWidget.parent`, and it never gave non-modal +dialogs focus, so they could disappear behind Electrum. + +To fix this *without changing the business logic*, all the "how is this window +shown / focused / parented" concerns are collected here. The rest of the GUI +code just calls these helpers, so the behaviour is consistent and easy to +audit. + +None of these helpers change *what* a dialog does — only its parenting, +modality and z-order/focus. +""" + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QWidget + + +def top_level_of(widget): + """Return the proper top-level window to use as a dialog parent. + + Electrum widgets expose ``top_level_window()``; when available we use it so + the dialog is anchored to the real top-level Electrum window (and therefore + stays in front of it). Falls back to the widget's own ``window()`` or the + widget itself. + """ + if widget is None: + return None + # Electrum's MessageBoxMixin / ElectrumWindow provide top_level_window(). + tlw = getattr(widget, "top_level_window", None) + if callable(tlw): + try: + return tlw() + except Exception: + pass + # Plain QWidget: window() returns the top-level container. + if isinstance(widget, QWidget): + try: + return widget.window() + except Exception: + pass + return widget + + +def bring_to_front(dialog): + """Make a *visible* dialog actually appear in front and take focus. + + ``raise_()`` alone is not enough on some window managers (notably Windows): + without ``activateWindow()`` the dialog can stay behind the main window. + """ + try: + dialog.raise_() + dialog.activateWindow() + except Exception: + pass + + +def stop_thread(thread): + """Safely stop and join an Electrum ``TaskThread`` if present. + + The original code commented out thread teardown, leaving background + threads running after a dialog closed (which could touch destroyed widgets + or keep network connections open until Electrum was restarted). This + stops the thread and waits for it to finish, guarding against ``None`` and + any teardown error. + """ + if thread is None: + return + try: + thread.stop() + except Exception: + pass + try: + thread.wait() + except Exception: + pass + + +def show_modal(dialog): + """Show *dialog* modally and return the result of ``exec()``. + + Modal dialogs always stay in front of their parent, which is the desired + behaviour for the plugin's editing/confirmation dialogs. + """ + try: + dialog.setWindowModality(Qt.WindowModality.WindowModal) + except Exception: + pass + bring_to_front(dialog) + return dialog.exec() + + +def show_on_top(dialog, *, modal_to_window=True): + """Show *dialog* non-modally but guaranteed in front of Electrum. + + Use this for the few dialogs that must remain non-modal (e.g. the + transaction dialog the user may want to keep open alongside the wallet). + It sets window-modality (so it stays above its parent window without + blocking the whole application) and gives it focus. + + Set ``modal_to_window=False`` for a completely modeless window. + """ + try: + if modal_to_window: + dialog.setWindowModality(Qt.WindowModality.WindowModal) + else: + dialog.setWindowModality(Qt.WindowModality.NonModal) + except Exception: + pass + dialog.show() + bring_to_front(dialog) + return dialog