From 4ce16d96a45936d8eabb5fd8f3b07782522ac716 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Tue, 15 Sep 2026 17:27:51 +0000 Subject: [PATCH 01/12] qt: (trivial) don't rely on implicit `1`->`True` conversion --- electrum/plugins/trustedcoin/qt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/electrum/plugins/trustedcoin/qt.py b/electrum/plugins/trustedcoin/qt.py index 9b76d7482287..31bccfcda8aa 100644 --- a/electrum/plugins/trustedcoin/qt.py +++ b/electrum/plugins/trustedcoin/qt.py @@ -125,7 +125,7 @@ def auth_dialog(self, window): vbox.addLayout(grid) msg = _('If you have lost your second factor, you need to restore your wallet from seed in order to request a new code.') label = QLabel(msg) - label.setWordWrap(1) + label.setWordWrap(True) vbox.addWidget(label) vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) if not d.exec(): @@ -186,7 +186,7 @@ def show_settings_dialog(self, window, success): msg = _('This wallet is protected by TrustedCoin\'s two-factor authentication.') + '
'\ + _("For more information, visit") + " https://api.trustedcoin.com/#/electrum-help" label = QLabel(msg) - label.setOpenExternalLinks(1) + label.setOpenExternalLinks(True) hbox.addStretch(10) hbox.addWidget(logo) @@ -199,7 +199,7 @@ def show_settings_dialog(self, window, success): msg = _('TrustedCoin charges a small fee to co-sign transactions. The fee depends on how many prepaid transactions you buy. An extra output is added to your transaction every time you run out of prepaid transactions.') + '
' label = QLabel(msg) - label.setWordWrap(1) + label.setWordWrap(True) vbox.addWidget(label) vbox.addStretch(10) From 065ea702e345a7cda314040ded25d3055e88af45 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 25 Sep 2026 14:45:51 +0000 Subject: [PATCH 02/12] qml: android build: patch Qt to default Label.textFormat to PlainText Security-by-default, instead of convenience: require programmer to opt-in to RichText. This patches Qt only for the Android build, and adds a runtime regression check. We mainly use the QML GUI for Android. However this leaves the Linux desktop dev environment and potential Linux phone users uncovered :/ --- contrib/android/p4a_recipes/qt6/__init__.py | 4 ++++ .../qt-6-10-rich-text-should-be-opt-in.patch | 14 ++++++++++++++ electrum/gui/qml/components/main.qml | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 contrib/android/p4a_recipes/qt6/patches/qt-6-10-rich-text-should-be-opt-in.patch diff --git a/contrib/android/p4a_recipes/qt6/__init__.py b/contrib/android/p4a_recipes/qt6/__init__.py index dcb443d9a398..3a8acef03923 100644 --- a/contrib/android/p4a_recipes/qt6/__init__.py +++ b/contrib/android/p4a_recipes/qt6/__init__.py @@ -13,5 +13,9 @@ class Qt6RecipePinned(util.InheritedRecipeMixin, Qt6Recipe): sha512sum = "bf1a1d42d57b4d2e77f7227f4cbe01e847fd65035461b89481063b32f25a57be6e5a07889acc4af65ca9ff9d27b7fe63bd2fe60b8aa7fa19d554394d799fbaa1" + patches = Qt6Recipe.patches + [ + os.path.join(os.path.dirname(__file__), "patches", "qt-6-10-rich-text-should-be-opt-in.patch"), + ] + recipe = Qt6RecipePinned() diff --git a/contrib/android/p4a_recipes/qt6/patches/qt-6-10-rich-text-should-be-opt-in.patch b/contrib/android/p4a_recipes/qt6/patches/qt-6-10-rich-text-should-be-opt-in.patch new file mode 100644 index 000000000000..5a33022db13a --- /dev/null +++ b/contrib/android/p4a_recipes/qt6/patches/qt-6-10-rich-text-should-be-opt-in.patch @@ -0,0 +1,14 @@ +# Set default textFormat of Labels and all other controls to PlainText. +# Security-by-default, instead of convenience: require programmer to opt-in to RichText. + +--- a/qtdeclarative/src/quick/items/qquicktext.cpp ++++ b/qtdeclarative/src/quick/items/qquicktext.cpp +@@ -52,7 +52,7 @@ QQuickTextPrivate::QQuickTextPrivate() + , color(0xFF000000), linkColor(0xFF0000FF), styleColor(0xFF000000) + , lineCount(1), multilengthEos(-1) + , elideMode(QQuickText::ElideNone), hAlign(QQuickText::AlignLeft), vAlign(QQuickText::AlignTop) +- , format(QQuickText::AutoText), wrapMode(QQuickText::NoWrap) ++ , format(QQuickText::PlainText), wrapMode(QQuickText::NoWrap) + , style(QQuickText::Normal) + , renderType(QQuickTextUtil::textRenderType()) + , updateType(UpdatePaintNode) diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 2ea3eb41ad90..4bbcef5b82e3 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -929,4 +929,22 @@ ApplicationWindow property var _lastActive: 0 // record time of last activity property bool _lockDialogShown: false + // We want all Text/Label/etc components to use PlainText by default. + // Qt normally defaults to AutoText, which allows rich text. + // For our Android builds, we patch Qt at compile-time to change this. + // (see "qt-6-10-rich-text-should-be-opt-in.patch") + // FIXME other platforms? (e.g. running QML on desktop Linux / dev environment) + // This runtime check here aims to prevent regressions by hard-failing. + Label { + id: richtext_sanity_label + Component.onCompleted: { + if (richtext_sanity_label.textFormat !== 0 && AppController.isAndroid()) { + console.log( + "richtext_sanity_label failed check: expected PlainText, " + + "got " + richtext_sanity_label.textFormat + ". Exiting...") + Qt.callLater(Qt.quit) + } + } + } + } From 841529b4fd514c9edfc243253e83475fd4ca0608 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 14 Sep 2026 21:06:29 +0000 Subject: [PATCH 03/12] qml: explicitly opt-in to RichText where used --- electrum/gui/qml/components/ExceptionDialog.qml | 1 + electrum/gui/qml/components/OpenWalletDialog.qml | 1 + electrum/gui/qml/components/Preferences.qml | 1 + electrum/gui/qml/components/controls/TxInput.qml | 1 + electrum/gui/qml/components/wizard/WCCreateSeed.qml | 1 + electrum/gui/qml/components/wizard/WCEnterExt.qml | 1 + 6 files changed, 6 insertions(+) diff --git a/electrum/gui/qml/components/ExceptionDialog.qml b/electrum/gui/qml/components/ExceptionDialog.qml index e9aabd5be13c..41d50b9948d7 100644 --- a/electrum/gui/qml/components/ExceptionDialog.qml +++ b/electrum/gui/qml/components/ExceptionDialog.qml @@ -131,6 +131,7 @@ ElDialog text: reportText wrapMode: Text.Wrap width: parent.width + textFormat: Text.RichText } } onClosed: destroy() diff --git a/electrum/gui/qml/components/OpenWalletDialog.qml b/electrum/gui/qml/components/OpenWalletDialog.qml index dbc5dad70568..d4d3acebb2b5 100644 --- a/electrum/gui/qml/components/OpenWalletDialog.qml +++ b/electrum/gui/qml/components/OpenWalletDialog.qml @@ -46,6 +46,7 @@ ElDialog { text: Daemon.singlePasswordEnabled || isStartup ? qsTr('Please enter password') : qsTr('Wallet %1 requires password to unlock').arg(name) + textFormat: Text.RichText compact: true iconStyle: InfoTextArea.IconStyle.Info backgroundColor: constants.darkerDialogBackground diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index 8595840875b2..a8db11924b4b 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -369,6 +369,7 @@ Pane { Label { Layout.fillWidth: true text: qsTr('%1% of payment').arg(maxfeeslider._fees[maxfeeslider.value]/10000) + textFormat: Text.RichText wrapMode: Text.Wrap } diff --git a/electrum/gui/qml/components/controls/TxInput.qml b/electrum/gui/qml/components/controls/TxInput.qml index b876bc4aacd2..8f5a7a009746 100644 --- a/electrum/gui/qml/components/controls/TxInput.qml +++ b/electrum/gui/qml/components/controls/TxInput.qml @@ -42,6 +42,7 @@ TextHighlightPane { : '<' + qsTr('unknown amount') + '>' font.pixelSize: constants.fontSizeMedium font.family: FixedFont + textFormat: Text.RichText } Label { text: Config.baseUnit diff --git a/electrum/gui/qml/components/wizard/WCCreateSeed.qml b/electrum/gui/qml/components/wizard/WCCreateSeed.qml index 50420a44070e..807e92906eaa 100644 --- a/electrum/gui/qml/components/wizard/WCCreateSeed.qml +++ b/electrum/gui/qml/components/wizard/WCCreateSeed.qml @@ -49,6 +49,7 @@ WizardComponent { Layout.fillWidth: true backgroundColor: constants.darkerDialogBackground iconStyle: InfoTextArea.IconStyle.Warn + textFormat: Text.RichText } Label { diff --git a/electrum/gui/qml/components/wizard/WCEnterExt.qml b/electrum/gui/qml/components/wizard/WCEnterExt.qml index 718906c3f28d..5399319b8a01 100644 --- a/electrum/gui/qml/components/wizard/WCEnterExt.qml +++ b/electrum/gui/qml/components/wizard/WCEnterExt.qml @@ -76,6 +76,7 @@ WizardComponent { '
', qsTr('Do not enable it unless you know what it does!'), ].join(' ') + textFormat: Text.RichText } ElCheckBox { From 03c9cb130e4b65c952f884b0fe98a09d1a293c78 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 14 Sep 2026 20:51:23 +0000 Subject: [PATCH 04/12] qt: set textFormat=PlainText for all QLabels dynamically You have to opt-in to rich text if you want it for your label! Upstream chose convenience-by-default for their GUI framework. Shame. :) ----- earlier, less satisfactory attempt: ``` def eventFilter(self, obj: QObject, event: QEvent) -> bool: if not isinstance(event, QtCore.QChildEvent): return False # - ChildAdded event: already constructed QWidget gets parented # - note: does *not* fire if parent gets set at construction time, e.g. Label("", parent=self) # - ChildPolished event: # - see https://doc.qt.io/qt-6/qstyle.html#polish : # > This function is called for every widget at some point after it has been fully created # > but just before it is shown for the very first time. if event.type() not in (QEvent.Type.ChildAdded, QEvent.Type.ChildPolished): return False child = event.child() if not isinstance(child, QLabel): return False if child.textFormat() != Qt.TextFormat.AutoText: # non-default textFormat => we leave it alone return False child.setTextFormat(Qt.TextFormat.PlainText) return False ``` --- electrum/gui/qt/__init__.py | 39 ++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 06163b5cc8c4..40c12b44d84a 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -39,8 +39,8 @@ "you may try 'sudo apt-get install python3-pyqt6'") from e from PyQt6.QtGui import QGuiApplication, QCursor -from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QWidget, QMenu, QMessageBox, QDialog, QToolTip -from PyQt6.QtCore import QObject, pyqtSignal, QTimer, Qt +from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QWidget, QMenu, QMessageBox, QDialog, QToolTip, QLabel +from PyQt6.QtCore import QObject, pyqtSignal, QTimer, Qt, QEvent import PyQt6.QtCore as QtCore @@ -112,9 +112,6 @@ def eventFilter(self, obj, event): class ScreenshotProtectionEventFilter(QObject): - def __init__(self): - super().__init__() - def eventFilter(self, obj, event): if ( event.type() == QtCore.QEvent.Type.Show @@ -125,6 +122,36 @@ def eventFilter(self, obj, event): return False +class InjectNoRichTextEventFilter(QObject): + """Set the default textFormat of all QLabels to PlainText. + + note: this also affects e.g. QMessageBox as it uses a QLabel internally. + FIXME if obj is a QLabel and it contains rich text, has the rich text already been parsed + and acted upon by the time the Polish event is emitted? For example, if the rich text + contains and embedded base64-encoded PNG and there is a vuln in the PNG parser, + is it already too late? + E.g. apparently if using a screen reader, the accessibility bridge queries the label’s text + from inside setText(), and Qt parses the HTML at that moment. So "Polish" is too late there. + In general against parser vulns, it is not even safe to pass untrusted text to the Label() + constructor... Instead: + lbl = Label(); lbl.setTextFormat(Qt.TextFormat.PlainText); lbl.setText(untrusted_text); + should be used... :/ + """ + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + if event.type() != QEvent.Type.Polish: + # see https://doc.qt.io/qt-6/qstyle.html#polish : + # > This function [QStyle.polish()] is called for every widget at some point after + # > it has been fully created but just before it is shown for the very first time. + return False + if not isinstance(obj, QLabel): + return False + if obj.textFormat() != Qt.TextFormat.AutoText: + # non-default textFormat => we leave it alone + return False + obj.setTextFormat(Qt.TextFormat.PlainText) + return False + + class QElectrumApplication(QApplication): new_window_signal = pyqtSignal(str, object) quit_signal = pyqtSignal() @@ -162,6 +189,8 @@ def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugin self.screenshot_protection_efilter = ScreenshotProtectionEventFilter() if sys.platform in ['win32', 'windows'] and self.config.GUI_QT_SCREENSHOT_PROTECTION: self.app.installEventFilter(self.screenshot_protection_efilter) + self.efilter_no_rich_text = InjectNoRichTextEventFilter() + self.app.installEventFilter(self.efilter_no_rich_text) # explicitly set 'AA_DontShowIconsInMenus' False so menu icons are shown on MacOS self.app.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus, on=False) self.app.setWindowIcon(read_QIcon("electrum.png")) From 5f4a054ddc4c813ceefb70b24840767f1c29c58e Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 25 Sep 2026 14:04:40 +0000 Subject: [PATCH 05/12] qt: standalone_exception_dialog: force plaintext this special-case QApplication is not covered by InjectNoRichTextEventFilter --- electrum/gui/qt/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 40c12b44d84a..4a4606290c6a 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -655,6 +655,7 @@ def standalone_exception_dialog(exception: Union[str, BaseException]) -> None: app = QApplication([]) msg_box = QMessageBox() + msg_box.setTextFormat(Qt.TextFormat.PlainText) msg_box.setWindowTitle(_("Error starting Electrum")) msg_box.setIcon(QMessageBox.Icon.Critical) msg_box.setText(_("An error occurred") + ":") From c83992f77f2568905bf35f23d357e09d901ea104 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 14 Sep 2026 21:07:03 +0000 Subject: [PATCH 06/12] qt: explicitly opt-in to RichText where used --- electrum/gui/qt/channel_details.py | 2 ++ electrum/gui/qt/console.py | 1 + electrum/gui/qt/exception_window.py | 3 ++- electrum/gui/qt/history_list.py | 4 +++- electrum/gui/qt/password_dialog.py | 2 ++ electrum/gui/qt/seed_dialog.py | 2 ++ electrum/gui/qt/transaction_dialog.py | 2 ++ electrum/gui/qt/update_checker.py | 2 ++ electrum/gui/qt/util.py | 1 + electrum/gui/qt/wizard/wizard.py | 1 + electrum/plugins/coldcard/qt.py | 1 + electrum/plugins/revealer/qt.py | 9 ++++++--- electrum/plugins/trustedcoin/qt.py | 5 ++++- 13 files changed, 29 insertions(+), 6 deletions(-) diff --git a/electrum/gui/qt/channel_details.py b/electrum/gui/qt/channel_details.py index 42da866d0085..3d9499a831df 100644 --- a/electrum/gui/qt/channel_details.py +++ b/electrum/gui/qt/channel_details.py @@ -3,6 +3,7 @@ import PyQt6.QtGui as QtGui import PyQt6.QtWidgets as QtWidgets import PyQt6.QtCore as QtCore +from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QLabel, QHBoxLayout from electrum.util import ShortID @@ -33,6 +34,7 @@ class LinkedLabel(QtWidgets.QLabel): def __init__(self, text, on_clicked): super().__init__(text) self.linkActivated.connect(on_clicked) + self.setTextFormat(Qt.TextFormat.RichText) class ChannelDetailsDialog(QtWidgets.QDialog, MessageBoxMixin, QtEventListener): diff --git a/electrum/gui/qt/console.py b/electrum/gui/qt/console.py index e953c3c5bc7e..1a705232aee2 100644 --- a/electrum/gui/qt/console.py +++ b/electrum/gui/qt/console.py @@ -37,6 +37,7 @@ def __init__(self, text, parent): self.setMargin(0) parent.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setWordWrap(True) + self.setTextFormat(Qt.TextFormat.RichText) def mousePressEvent(self, e): self.hide() diff --git a/electrum/gui/qt/exception_window.py b/electrum/gui/qt/exception_window.py index 848b97cc076c..141c2a887e65 100644 --- a/electrum/gui/qt/exception_window.py +++ b/electrum/gui/qt/exception_window.py @@ -60,6 +60,7 @@ def __init__(self, config: 'SimpleConfig', exctype, value, tb): main_box = QVBoxLayout() heading = QLabel('

' + BaseCrashReporter.CRASH_TITLE + '

') + heading.setTextFormat(Qt.TextFormat.RichText) main_box.addWidget(heading) main_box.addWidget(QLabel(BaseCrashReporter.CRASH_MESSAGE)) @@ -210,7 +211,7 @@ def __init__(self, *, parent: QWidget, text: str): report_text = QLabel(text) report_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - report_text.setTextFormat(Qt.TextFormat.AutoText) # likely rich text + report_text.setTextFormat(Qt.TextFormat.RichText) scroll_area.setWidget(report_text) vbox.addWidget(scroll_area) diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py index c1d44fff8a05..e56872b6fce5 100644 --- a/electrum/gui/qt/history_list.py +++ b/electrum/gui/qt/history_list.py @@ -627,7 +627,9 @@ def show_summary(self): d.setMinimumSize(600, 150) vbox = QVBoxLayout() msg = messages.to_rtf(messages.MSG_CAPITAL_GAINS) - vbox.addWidget(WWLabel(msg)) + lbl = WWLabel(msg) + lbl.setTextFormat(Qt.TextFormat.RichText) + vbox.addWidget(lbl) grid = QGridLayout() grid.addWidget(QLabel(_("Begin")), 0, 1) grid.addWidget(QLabel(_("End")), 0, 2) diff --git a/electrum/gui/qt/password_dialog.py b/electrum/gui/qt/password_dialog.py index 44e11434ffde..11acb9d1e55d 100644 --- a/electrum/gui/qt/password_dialog.py +++ b/electrum/gui/qt/password_dialog.py @@ -121,6 +121,7 @@ def __init__(self, msg, kind, OK_button, wallet=None): # Password Strength Label if kind != PW_PASSPHRASE: self.pw_strength = QLabel() + self.pw_strength.setTextFormat(Qt.TextFormat.RichText) grid.addWidget(self.pw_strength, 3, 0, 1, 2) self.new_pw.textChanged.connect(self.pw_changed) @@ -294,6 +295,7 @@ def __init__(self, parent=None, msg=None): WindowModalDialog.__init__(self, parent, _("Enter Password")) self.pw = pw = PasswordLineEdit() label = QLabel(msg) + label.setTextFormat(Qt.TextFormat.RichText) label.setWordWrap(True) vbox = QVBoxLayout() vbox.addWidget(label) diff --git a/electrum/gui/qt/seed_dialog.py b/electrum/gui/qt/seed_dialog.py index 0b0da25ae3bb..6f8f36108042 100644 --- a/electrum/gui/qt/seed_dialog.py +++ b/electrum/gui/qt/seed_dialog.py @@ -180,8 +180,10 @@ def __init__( vbox.addStretch(1) self.seed_status = WWLabel('') + self.seed_status.setTextFormat(Qt.TextFormat.RichText) vbox.addWidget(self.seed_status) self.seed_warning = WWLabel('') + self.seed_warning.setTextFormat(Qt.TextFormat.RichText) if msg: self.seed_warning.setText(seed_warning_msg(seed)) else: diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index 27a8e097f5f2..ea9b478a34ec 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -1019,6 +1019,7 @@ def add_tx_stats(self, vbox): fee_hbox = QHBoxLayout() self.fee_label = TxDetailLabel() + self.fee_label.setTextFormat(Qt.TextFormat.RichText) fee_hbox.addWidget(self.fee_label) self.fee_warning_icon = QLabel() pixmap = QPixmap(icon_path("warning")) @@ -1121,6 +1122,7 @@ def __init__( font.setPointSize(font.pointSize() - 1) self.legend_label.setFont(font) self.legend_label.setVisible(False) + self.legend_label.setTextFormat(Qt.TextFormat.RichText) self.text_char_format = QTextCharFormat() self.text_char_format.setBackground(QBrush(self.color)) self.text_char_format.setToolTip(tooltip) diff --git a/electrum/gui/qt/update_checker.py b/electrum/gui/qt/update_checker.py index f3c918c5bc07..fe19207c59ce 100644 --- a/electrum/gui/qt/update_checker.py +++ b/electrum/gui/qt/update_checker.py @@ -35,9 +35,11 @@ def __init__(self, *, latest_version=None): self.content.setContentsMargins(*[10]*4) self.heading_label = QLabel() + self.heading_label.setTextFormat(Qt.TextFormat.RichText) self.content.addWidget(self.heading_label) self.detail_label = QLabel() + self.detail_label.setTextFormat(Qt.TextFormat.RichText) self.detail_label.setTextInteractionFlags(Qt.TextInteractionFlag.LinksAccessibleByMouse) self.detail_label.setOpenExternalLinks(True) self.content.addWidget(self.detail_label) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index f5329df1ab9b..c984a4f3562d 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -120,6 +120,7 @@ def __init__(self, text='', parent=None): WWLabel.__init__(self, text, parent) self.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.setOpenExternalLinks(True) + self.setTextFormat(Qt.TextFormat.RichText) class AmountLabel(QLabel): diff --git a/electrum/gui/qt/wizard/wizard.py b/electrum/gui/qt/wizard/wizard.py index 19afa2b828cc..68cbad5d4905 100644 --- a/electrum/gui/qt/wizard/wizard.py +++ b/electrum/gui/qt/wizard/wizard.py @@ -39,6 +39,7 @@ def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', *, start self.setMinimumSize(600, 400) self.title = QLabel() + self.title.setTextFormat(Qt.TextFormat.RichText) self.window_title = '' self.finish_label = _('Finish') diff --git a/electrum/plugins/coldcard/qt.py b/electrum/plugins/coldcard/qt.py index 6b80ed01ad64..2f33ccb7cfb4 100644 --- a/electrum/plugins/coldcard/qt.py +++ b/electrum/plugins/coldcard/qt.py @@ -193,6 +193,7 @@ def connect_and_doit(): for row_num, (member_name, label) in enumerate(rows): # XXX we know xfp already, even if not connected widget = QLabel('000000000000') + widget.setTextFormat(Qt.TextFormat.RichText) widget.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard) grid.addWidget(QLabel(label), y, 0, 1, 1, Qt.AlignmentFlag.AlignRight) diff --git a/electrum/plugins/revealer/qt.py b/electrum/plugins/revealer/qt.py index 6feb44412a10..11a0292dd947 100644 --- a/electrum/plugins/revealer/qt.py +++ b/electrum/plugins/revealer/qt.py @@ -364,7 +364,9 @@ def cypherseed_dialog(self, window): self.custom_secret_maximum_characters_warning_label = QLabel("" + _("This version supports a maximum of {} characters.").format(self.MAX_PLAINTEXT_LEN) +"") + self.custom_secret_maximum_characters_warning_label.setTextFormat(Qt.TextFormat.RichText) one_time_pad_warning_label = QLabel("" + _("Warning ") + ": " + _("each Revealer is a one-time-pad, use it for a single secret.")) + one_time_pad_warning_label.setTextFormat(Qt.TextFormat.RichText) # Allow users to select text in the labels. ready_to_encrypt_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) @@ -853,9 +855,10 @@ def calibration_dialog(self, window): d.setMinimumSize(100, 200) vbox = QVBoxLayout(d) - vbox.addWidget(QLabel(''.join(["
", _("If you have an old printer, or want optimal precision"),"
", - _("print the calibration pdf and follow the instructions "), "
","
", - ]))) + vbox.addWidget(QLabel("".join([ + _("If you have an old printer, or want optimal precision"), "\n", + _("print the calibration pdf and follow the instructions "), "\n\n", + ]))) self.calibration_h = self.config.get('calibration_h') self.calibration_v = self.config.get('calibration_v') cprint = QPushButton(_("Open calibration pdf")) diff --git a/electrum/plugins/trustedcoin/qt.py b/electrum/plugins/trustedcoin/qt.py index 31bccfcda8aa..4fd049995355 100644 --- a/electrum/plugins/trustedcoin/qt.py +++ b/electrum/plugins/trustedcoin/qt.py @@ -187,6 +187,7 @@ def show_settings_dialog(self, window, success): + _("For more information, visit") + " https://api.trustedcoin.com/#/electrum-help" label = QLabel(msg) label.setOpenExternalLinks(True) + label.setTextFormat(Qt.TextFormat.RichText) hbox.addStretch(10) hbox.addWidget(logo) @@ -197,7 +198,9 @@ def show_settings_dialog(self, window, success): vbox.addLayout(hbox) vbox.addStretch(10) - msg = _('TrustedCoin charges a small fee to co-sign transactions. The fee depends on how many prepaid transactions you buy. An extra output is added to your transaction every time you run out of prepaid transactions.') + '
' + msg = _('TrustedCoin charges a small fee to co-sign transactions. ' + 'The fee depends on how many prepaid transactions you buy. ' + 'An extra output is added to your transaction every time you run out of prepaid transactions.') label = QLabel(msg) label.setWordWrap(True) vbox.addWidget(label) From 8c1591b471bd4a99d56b1206c6f11523a0344157 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Tue, 15 Sep 2026 16:37:41 +0000 Subject: [PATCH 07/12] qt: util.custom_message_box: use explicit RichText instead of AutoText - InjectNoRichTextEventFilter is changing AutoText to PlainText, and that also affects the internal QLabel inside the message box --- electrum/gui/qt/util.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index c984a4f3562d..66453b43b6a9 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -384,11 +384,7 @@ def custom_message_box( d.setDefaultButton(defaultButton) if rich_text: d.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.LinksAccessibleByMouse) - # set AutoText instead of RichText - # AutoText lets Qt figure out whether to render as rich text. - # e.g. if text is actually plain text and uses "\n" newlines; - # and we set RichText here, newlines would be swallowed - d.setTextFormat(Qt.TextFormat.AutoText) + d.setTextFormat(Qt.TextFormat.RichText) else: d.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) d.setTextFormat(Qt.TextFormat.PlainText) From ba24f1e2864d62ec9f7cdff563b31dfa8694f63f Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 25 Sep 2026 14:00:13 +0000 Subject: [PATCH 08/12] qt: util.custom_message_box: force plain text before setText() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llm output: > Linux with a screen reader running. Here the accessibility bridge queries the label’s text from inside > setText(), and Qt parses the HTML at that moment. This also affects custom_message_box(rich_text=False), > which passes the text to the QMessageBox constructor before switching it to PlainText. --- electrum/gui/qt/util.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 66453b43b6a9..3c021f98e83f 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -374,10 +374,10 @@ def custom_message_box( else: custom_buttons.append(button) if type(icon) is QPixmap: - d = QMessageBox(QMessageBox.Icon.Information, title, str(text), standard_buttons, parent) + d = QMessageBox(QMessageBox.Icon.Information, title, "", standard_buttons, parent) d.setIconPixmap(icon) else: - d = QMessageBox(icon, title, str(text), standard_buttons, parent) + d = QMessageBox(icon, title, "", standard_buttons, parent) for button, role, _ in custom_buttons: d.addButton(button, role) d.setWindowModality(Qt.WindowModality.WindowModal) @@ -388,6 +388,7 @@ def custom_message_box( else: d.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) d.setTextFormat(Qt.TextFormat.PlainText) + d.setText(str(text)) # set the text only after setting textFormat, so unwanted rich text is left unparsed if checkbox is not None: d.setCheckBox(checkbox) result = d.exec() From 47e25e7d47bf54aa9f022927ded9d21ce3064e4d Mon Sep 17 00:00:00 2001 From: SomberNight Date: Tue, 15 Sep 2026 17:02:41 +0000 Subject: [PATCH 09/12] qt: util.HelpMixin: change "rich_text" default to False --- electrum/gui/qt/network_dialog.py | 2 +- electrum/gui/qt/util.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/electrum/gui/qt/network_dialog.py b/electrum/gui/qt/network_dialog.py index 6936d8665de6..000ba6ae0002 100644 --- a/electrum/gui/qt/network_dialog.py +++ b/electrum/gui/qt/network_dialog.py @@ -386,7 +386,7 @@ def __init__(self, network: Network, parent=None): """ ) - grid.addWidget(HelpButton(msg), 0, 4) + grid.addWidget(HelpButton(msg, rich_text=True), 0, 4) grid.addWidget(self.connect_combo, 0, 1, 1, 3) self.server_e = QLineEdit() diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 3c021f98e83f..022961f8e638 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -147,9 +147,10 @@ def setVisible(self, visible): class HelpMixin: - def __init__(self, help_text: str, *, help_title: str | None = None): + def __init__(self, help_text: str, *, help_title: str | None = None, rich_text: bool = False): assert isinstance(self, QWidget), "HelpMixin must be a QWidget instance!" self.help_text = help_text + self.rich_text = rich_text self._help_title = help_title or _('Help') if isinstance(self, QLabel): self.setTextInteractionFlags( @@ -162,15 +163,15 @@ def show_help(self): parent=self, title=self._help_title, text=self.help_text, - rich_text=True, + rich_text=self.rich_text, ) class HelpLabel(HelpMixin, QLabel): - def __init__(self, text: str, help_text: str): + def __init__(self, text: str, help_text: str, *, rich_text: bool = False): QLabel.__init__(self, text) - HelpMixin.__init__(self, help_text) + HelpMixin.__init__(self, help_text, rich_text=rich_text) self.app = QCoreApplication.instance() self.font = self.font() @@ -195,9 +196,9 @@ def leaveEvent(self, event): class HelpButton(HelpMixin, QToolButton): - def __init__(self, text: str): + def __init__(self, text: str, *, rich_text: bool = False): QToolButton.__init__(self) - HelpMixin.__init__(self, text) + HelpMixin.__init__(self, text, rich_text=rich_text) self.setText('?') self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setFixedWidth(round(2.2 * char_width_in_lineedit())) @@ -205,9 +206,9 @@ def __init__(self, text: str): class InfoButton(HelpMixin, QPushButton): - def __init__(self, text: str): + def __init__(self, text: str, *, rich_text: bool = False): QPushButton.__init__(self, _('Info')) - HelpMixin.__init__(self, text, help_title=_('Info')) + HelpMixin.__init__(self, text, help_title=_('Info'), rich_text=rich_text) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setFixedWidth(6 * char_width_in_lineedit()) self.clicked.connect(self.show_help) From 8b51d6b19a0df0d2e67c155d1ef90f9e3c6eae11 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Tue, 15 Sep 2026 17:45:13 +0000 Subject: [PATCH 10/12] qt: adapt tooltip texts for being plaintext tooltips use QLabel internally, so due to InjectNoRichTextEventFilter, their textFormat is no longer AutoText, but is now PlainText instead. note: messages.to_rtf() was specifically introduced for usage with tooltips [0], I guess to nudge Qt to break longer lines more often (so that tooltips widths are smaller). Tooltips being forced to PlainText breaks that. I also cannot see an easy way to opt-in the tooltip of e.g. a QCheckBox to RichText. Instead, now we use the "textwrap" module from the stdlib to manually word-wrap the strings. [0]: https://github.com/spesmilo/electrum/commit/345c2b429547e9370afe7f8c50de44d727436c0d Co-authored-by: Alwoch --- electrum/gui/messages.py | 15 +++++++++++++++ electrum/gui/qt/my_treeview.py | 2 +- electrum/gui/qt/settings_dialog.py | 3 ++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/electrum/gui/messages.py b/electrum/gui/messages.py index c823eabd8d1b..dd9919516c66 100644 --- a/electrum/gui/messages.py +++ b/electrum/gui/messages.py @@ -1,3 +1,5 @@ +import textwrap + from electrum.i18n import _ from electrum.submarine_swaps import MIN_FINAL_CLTV_DELTA_FOR_CLIENT @@ -6,6 +8,19 @@ def to_rtf(msg): return '\n'.join(['

' + x + '

' for x in msg.split('\n\n')]) +def wrap_multi_paragraph_text(text: str) -> str: + """Word-wrap long lines. + + - If text contains multiple paragraphs, the paragraph-separation is kept. + - Useful for tooltips (shown on mouse-over), as Qt otherwise + only word-wraps lines longer than the screen-width. + """ + return "\n".join( + textwrap.fill(line) + for line in text.split("\n") + ) + + MSG_COOPERATIVE_CLOSE = _( """Your node will negotiate the transaction fee with the remote node. This method of closing the channel usually results in the lowest fees.""" ) diff --git a/electrum/gui/qt/my_treeview.py b/electrum/gui/qt/my_treeview.py index 1c1110ef5b20..a38971dcae4e 100644 --- a/electrum/gui/qt/my_treeview.py +++ b/electrum/gui/qt/my_treeview.py @@ -86,7 +86,7 @@ def addConfig( checked = bool(configvar.get()) tooltip = None if (long_desc := configvar.get_long_desc()) is not None: - tooltip = messages.to_rtf(long_desc) + tooltip = messages.wrap_multi_paragraph_text(long_desc) return self.addToggle( short_desc, lambda: self._do_toggle_config(configvar, callback=callback), diff --git a/electrum/gui/qt/settings_dialog.py b/electrum/gui/qt/settings_dialog.py index e37c83438b32..eb4e2c3775bd 100644 --- a/electrum/gui/qt/settings_dialog.py +++ b/electrum/gui/qt/settings_dialog.py @@ -50,7 +50,8 @@ def checkbox_from_configvar(cv: 'ConfigVarWithConfig') -> QCheckBox: assert short_desc is not None, f"short_desc missing for {cv}" cb = QCheckBox(short_desc) if (long_desc := cv.get_long_desc()) is not None: - cb.setToolTip(messages.to_rtf(long_desc)) + long_desc = messages.wrap_multi_paragraph_text(long_desc) + cb.setToolTip(long_desc) return cb From 518174233a6063ae21979cfe08e53f3394e80049 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Wed, 16 Sep 2026 14:11:10 +0000 Subject: [PATCH 11/12] qt: PasswordDialog: require opt-in to rich-text --- electrum/gui/qt/password_dialog.py | 3 +-- electrum/gui/qt/plugins_dialog.py | 2 +- electrum/gui/qt/util.py | 12 ++++++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qt/password_dialog.py b/electrum/gui/qt/password_dialog.py index 11acb9d1e55d..72c130d2bdd6 100644 --- a/electrum/gui/qt/password_dialog.py +++ b/electrum/gui/qt/password_dialog.py @@ -294,8 +294,7 @@ def __init__(self, parent=None, msg=None): msg = msg or _('Please enter your password') WindowModalDialog.__init__(self, parent, _("Enter Password")) self.pw = pw = PasswordLineEdit() - label = QLabel(msg) - label.setTextFormat(Qt.TextFormat.RichText) + self.label = label = QLabel(msg) label.setWordWrap(True) vbox = QVBoxLayout() vbox.addWidget(label) diff --git a/electrum/gui/qt/plugins_dialog.py b/electrum/gui/qt/plugins_dialog.py index 6e65df4c93ab..b170f0db690b 100644 --- a/electrum/gui/qt/plugins_dialog.py +++ b/electrum/gui/qt/plugins_dialog.py @@ -196,7 +196,7 @@ def get_plugins_privkey(self) -> Optional['ECPrivkey']: self.init_plugins_password() return None # ask for url and password, same window - pw = self.password_dialog(msg=messages.MSG_THIRD_PARTY_PLUGIN_WARNING) + pw = self.password_dialog(msg=messages.MSG_THIRD_PARTY_PLUGIN_WARNING, rich_text=True) if not pw: return None privkey = self.plugins.derive_privkey(pw, salt) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 022961f8e638..0c77e92ee2c6 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -345,10 +345,18 @@ def query_choice( return None return choice_widget.selected_key - def password_dialog(self, msg=None, parent=None): + def password_dialog( + self, + *, + msg: str | None = None, + parent: QWidget | None = None, + rich_text: bool = False, + ): from .password_dialog import PasswordDialog parent = parent or self - d = PasswordDialog(parent, msg) + d = PasswordDialog(parent=parent, msg=msg) + if rich_text: + d.label.setTextFormat(Qt.TextFormat.RichText) return d.run() From e2584017b776ba99098a783867885d388f310e0b Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 18 Sep 2026 15:52:20 +0000 Subject: [PATCH 12/12] plugins: manifest.json: "description" must now be plaintext - rich text can contain embedded base64-encoded images - qt docs say [0] it is not safe to parse untrusted input as an image [0]: https://doc.qt.io/qt-6/qtimageformats-index.html : > Security Considerations: > Since these file formats are more rarely used, the codecs may be > less thoroughly debugged against potential security holes. As always, > care should be taken when creating applications that may be used > to decode uncontrolled data files. --- electrum/gui/qt/plugins_dialog.py | 2 +- electrum/plugins/timelock_recovery/manifest.json | 2 +- electrum/plugins/trustedcoin/manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qt/plugins_dialog.py b/electrum/gui/qt/plugins_dialog.py index b170f0db690b..607eb69e547d 100644 --- a/electrum/gui/qt/plugins_dialog.py +++ b/electrum/gui/qt/plugins_dialog.py @@ -50,7 +50,7 @@ def __init__(self, name, metadata, status_button: Optional['PluginStatusButton'] name_label.setIcon(icon) vbox.addWidget(name_label) vbox.addStretch() - vbox.addWidget(WWLabel(description)) + vbox.addWidget(WWLabel(description)) # must be plain text: don't parse untrusted text as rich-text vbox.addStretch() form = QFormLayout(None) if author: diff --git a/electrum/plugins/timelock_recovery/manifest.json b/electrum/plugins/timelock_recovery/manifest.json index a35fd232262e..0e6cdcf7a895 100644 --- a/electrum/plugins/timelock_recovery/manifest.json +++ b/electrum/plugins/timelock_recovery/manifest.json @@ -1,6 +1,6 @@ { "fullname": "Timelock Recovery Utility", - "description": "
This plug-in allows you to create Timelock Recovery Plans for your wallet. See: timelockrecovery.com", + "description": "This plug-in allows you to create Timelock Recovery Plans for your wallet. See: https://timelockrecovery.com", "author": "orenz0@protonmail.com", "available_for": ["qt"], "icon":"timelock_recovery_60.png", diff --git a/electrum/plugins/trustedcoin/manifest.json b/electrum/plugins/trustedcoin/manifest.json index 7eb5631c06a2..d2916445ce4c 100644 --- a/electrum/plugins/trustedcoin/manifest.json +++ b/electrum/plugins/trustedcoin/manifest.json @@ -1,7 +1,7 @@ { "name": "trustedcoin", "fullname": "Two Factor Authentication", - "description": "This plugin adds two-factor authentication to your wallet.
For more information, visit https://api.trustedcoin.com/#/electrum-help", + "description": "This plugin adds two-factor authentication to your wallet.\nFor more information, visit https://api.trustedcoin.com/#/electrum-help", "requires_wallet_type": ["2fa"], "registers_wallet_type": "2fa", "icon":"trustedcoin-status.png",