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