diff --git a/bal/core/heirs.py b/bal/core/heirs.py index 4200f54..9a369cd 100644 --- a/bal/core/heirs.py +++ b/bal/core/heirs.py @@ -306,6 +306,42 @@ def get_change_output(wallet, in_amount, out_amount, fee): return out +def _json_safe(value, _path="heirs", _depth=0): + """Return a JSON-serializable deep copy of *value*. + + The wallet DB persists the heirs dict via ``json_db.put``, which calls + ``copy.deepcopy`` on the value. If any nested element is a live runtime + object (e.g. one holding a ``threading.RLock``), deepcopy raises + ``TypeError: cannot pickle '_thread.RLock' object`` and the whole + "Build will" task fails. + + To make persistence robust we coerce the structure to plain + JSON-compatible types (dict / list / str / int / float / bool / None). + Anything else is converted to ``str(value)`` and logged with its path so + the offending field can be identified, instead of crashing the task. + """ + # Primitive JSON scalars are kept as-is. + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, dict): + return { + str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1) + for k, v in value.items() + } + if isinstance(value, (list, tuple)): + return [ + _json_safe(v, "{}[{}]".format(_path, i), _depth + 1) + for i, v in enumerate(value) + ] + # Unexpected runtime object: do not let it reach deepcopy. Log where it + # was found so the real source can be fixed, then store a safe string. + _logger.error( + "heirs.save: non-serializable value at {} (type={}); coercing to str. " + "value={!r}".format(_path, type(value).__name__, value) + ) + return str(value) + + class Heirs(dict, Logger): def __init__(self, wallet): @@ -322,7 +358,11 @@ class Heirs(dict, Logger): invalidate_inheritance_transactions(wallet) def save(self): - self.db.put("heirs", dict(self)) + # Sanitise the heirs mapping before handing it to the wallet DB: this + # guarantees only JSON-serializable values are stored and prevents the + # "cannot pickle '_thread.RLock' object" failure that aborted the + # Build-will task when a runtime object slipped into an heir value. + self.db.put("heirs", _json_safe(dict(self))) def import_file(self, path): data = read_json_file(path) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index 9d2ab69..53126de 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -53,10 +53,10 @@ from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, Qt, QTimer, pyqtSignal) from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem, QStandardItemModel) -from PyQt6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, - QDateTimeEdit, QGridLayout, QHBoxLayout, QLabel, - QLineEdit, QTextEdit, QMenu, QMenuBar, QPushButton, - QScrollArea, QSizePolicy, QSpinBox, +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) @@ -116,18 +116,34 @@ class CheckAliveError(Exception): def log_error(exec_info, window=None): - _logger.error(f"LOG_ERROR: {exec_info}") - #tb = traceback.format_exc() - try: - tb=exec_info[1] - _logger.error(tb) - except Exception: - tb = traceback.format_exc() - _logger.error(tb) + """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: - window.show_error(exec_info) + # show_error expects a human-readable message, not a triple. + window.show_error(str(exc) if exc is not None else str(exec_info)) diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 4914e48..f187bf2 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -381,7 +381,10 @@ class BalWizardLocktimeAndFeeWidget(BalWizardWidget): widget = QWidget() layout = QVBoxLayout(widget) - layout.addWidget(WillSettingsWidget(self.bal_window, self, "v")) + # 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 diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index 04e8fe3..d52e588 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -61,6 +61,154 @@ class Plugin(BalPlugin): _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). @@ -124,11 +272,28 @@ class Plugin(BalPlugin): 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): @@ -142,6 +307,9 @@ class Plugin(BalPlugin): ) 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): diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 80f54d7..997d8b5 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -64,6 +64,23 @@ class BalTxFeesWidget(QWidget): 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() @@ -275,6 +292,17 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): 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): @@ -295,6 +323,14 @@ class TimeRawEditWidget(QWidget): 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): @@ -387,6 +423,24 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor): #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())) @@ -419,11 +473,15 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor): class ThresholdTimeWidget(BalTimeEditWidget): + # rich_text=True is used by the HelpButton, so HTML tags (,
) render. help_text = ( - "Check to ask for invalidation.\n\n" - "When less then this time is missing, ask to invalidate.\n" - "If you fail to invalidate during this time, your transactions will be delivered to your heirs.\n\n" - f"{BalTimeEditWidget.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" @@ -441,10 +499,14 @@ class ThresholdTimeWidget(BalTimeEditWidget): class LockTimeWidget(BalTimeEditWidget): + # rich_text=True is used by the HelpButton, so HTML tags (,
) render. help_text = ( - "Set Locktime for transactions.\n" - "Any time is needed transaction will be anticipated by 1day\n" - f"{BalTimeEditWidget.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" @@ -463,10 +525,15 @@ class LockTimeWidget(BalTimeEditWidget): class WillSettingsWidget(QWidget): - def __init__(self, bal_window: "BalWindow", parent, layout_type="h"): + 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() @@ -496,6 +563,11 @@ class WillSettingsWidget(QWidget): box.addWidget(self.calendar_button) box.addWidget(self.widgets["baltx_fees"]) + 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): days = (alarm_end - alarm_start).days+1 lines = [] diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 975d85a..f3e4cb6 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -432,11 +432,26 @@ class BalWindow: self.bal_plugin.WILL_SETTINGS.set(self.will_settings) def init_heirs_to_locktime(self, multiverse=False): - #pass - for heir in self.heirs: - h = self.heirs[heir] - if not multiverse: - self.heirs[heir] = [h[0], h[1], self.will_settings["locktime"]] + 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: