fix(qt): auto-close Plugins manager, read-only field styling, RLock-safe heirs persistence

GUI / plugin lifecycle:
- Auto-close Electrum's native 'Electrum Plugins' manager dialog after the
  BAL plugin is hot-enabled. Electrum 4.7.x no longer calls the old init_qt
  hook, so the close is now triggered from the create_status_bar, init_menubar
  and load_wallet hooks (fired when reload_windows() recreates the window).
- Robust dialog matching (isinstance / class name / localized window title) to
  cope with zipimport module-identity mismatches.
- Robust dismissal of the modal dialog (reject()/done()/close()) with a retry
  schedule [400, 800, 1500] ms; if it still cannot be closed, fall back to
  bringing it to the front (showNormal/raise_/activateWindow) so it never
  lingers hidden in the background. Counting only visible top-levels avoids
  treating an already-closed dialog as still open.

Read-only field styling:
- Paint the locked Delivery time / Check Alive date editors and the mining-fee
  spinbox with a light-grey background (#f0f0f0) so the user can see at a glance
  that they are not editable outside the 'Build your will' wizard; the styling
  is cleared when the fields are made editable again.

Pickle/RLock crash on 'Build will':
- heirs.save() now sanitises the heirs mapping via _json_safe() before handing
  it to json_db.put(), which deep-copies the value. A live runtime object
  (holding a threading.RLock) slipping into an heir value previously raised
  'TypeError: cannot pickle _thread.RLock object' and aborted the task; such
  values are now coerced to str and logged with their path.
- init_heirs_to_locktime() coerces the locktime to a plain serializable scalar.
- log_error() now accepts both a sys.exc_info() triple and a single exception
  instance, fixing the secondary 'TypeError object is not subscriptable' that
  masked the real error.
This commit is contained in:
GenSpark AI Developer
2026-06-15 20:35:55 +00:00
parent a8155183d7
commit 714b17eacd
6 changed files with 342 additions and 28 deletions

View File

@@ -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))

View File

@@ -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

View File

@@ -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):

View File

@@ -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 (<b>, <br>) 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}"
"<b>CHECK ALIVE</b><br><br>"
"Check to ask for invalidation.<br><br>"
"When less then this time is missing, ask to invalidate.<br>"
"If you fail to invalidate during this time, your transactions will be delivered to your heirs.<br><br>"
"if you choose Raw, you can insert various options based on suffix:<br>"
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
" - y: number of years after currrent day(ex: 1y means one year from today)<br>"
)
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 (<b>, <br>) render.
help_text = (
"Set Locktime for transactions.\n"
"Any time is needed transaction will be anticipated by 1day\n"
f"{BalTimeEditWidget.help_text}"
"<b>DELIVERY TIME</b><br><br>"
"Set Locktime for transactions.<br>"
"Any time is needed transaction will be anticipated by 1day<br><br>"
"if you choose Raw, you can insert various options based on suffix:<br>"
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
" - y: number of years after currrent day(ex: 1y means one year from today)<br>"
)
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 = []

View File

@@ -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: