forked from bitcoinafterlife/bal-electrum-plugin
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:
@@ -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))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user