diff --git a/.github/workflows/gui-functional-tests.yml b/.github/workflows/gui-functional-tests.yml index 427937133a..6bfded615d 100644 --- a/.github/workflows/gui-functional-tests.yml +++ b/.github/workflows/gui-functional-tests.yml @@ -146,6 +146,7 @@ jobs: python3 test/functional/qml_test_bridge_sanity.py python3 test/functional/qml_test_onboarding.py python3 test/functional/qml_test_preinit_onboarding.py + python3 test/functional/qml_test_external_link_confirm.py python3 test/functional/qml_test_disablewallet_boot.py python3 test/functional/qml_test_blockclock.py python3 test/functional/qml_test_blocksonly_settings.py diff --git a/qml/bitcoin.cpp b/qml/bitcoin.cpp index c02b985c4a..977e3c8285 100644 --- a/qml/bitcoin.cpp +++ b/qml/bitcoin.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #ifdef __ANDROID__ #include #endif @@ -156,7 +157,7 @@ AppMode SetupAppMode() return AppMode(mode, WalletEnabledFromArgs()); } -void RegisterQmlTypes(AppMode& app_mode, BuildInfo& build_info, Clipboard& clipboard, BitcoinUriModel& bitcoin_uri_model); +void RegisterQmlTypes(AppMode& app_mode, BuildInfo& build_info, Clipboard& clipboard, UrlOpener& url_opener, BitcoinUriModel& bitcoin_uri_model); bool InitErrorMessageBox( const bilingual_str& message, @@ -165,8 +166,9 @@ bool InitErrorMessageBox( static AppMode error_app_mode = SetupAppMode(); static BuildInfo error_build_info; static Clipboard error_clipboard; + static UrlOpener error_url_opener; static BitcoinUriModel error_bitcoin_uri_model; - RegisterQmlTypes(error_app_mode, error_build_info, error_clipboard, error_bitcoin_uri_model); + RegisterQmlTypes(error_app_mode, error_build_info, error_clipboard, error_url_opener, error_bitcoin_uri_model); QQmlApplicationEngine engine; @@ -233,17 +235,19 @@ void ApplyTestSettingsDir() } #endif -void RegisterQmlTypes(AppMode& app_mode, BuildInfo& build_info, Clipboard& clipboard, BitcoinUriModel& bitcoin_uri_model) +void RegisterQmlTypes(AppMode& app_mode, BuildInfo& build_info, Clipboard& clipboard, UrlOpener& url_opener, BitcoinUriModel& bitcoin_uri_model) { static bool registered{false}; static AppMode* app_mode_instance{nullptr}; static BuildInfo* build_info_instance{nullptr}; static Clipboard* clipboard_instance{nullptr}; + static UrlOpener* url_opener_instance{nullptr}; static BitcoinUriModel* bitcoin_uri_model_instance{nullptr}; if (registered) return; app_mode_instance = &app_mode; build_info_instance = &build_info; clipboard_instance = &clipboard; + url_opener_instance = &url_opener; bitcoin_uri_model_instance = &bitcoin_uri_model; qmlRegisterSingletonType("org.bitcoincore.qt", 1, 0, "AppMode", [](QQmlEngine*, QJSEngine*) -> QObject* { @@ -258,6 +262,10 @@ void RegisterQmlTypes(AppMode& app_mode, BuildInfo& build_info, Clipboard& clipb QQmlEngine::setObjectOwnership(clipboard_instance, QQmlEngine::CppOwnership); return clipboard_instance; }); + qmlRegisterSingletonType("org.bitcoincore.qt", 1, 0, "UrlOpener", [](QQmlEngine*, QJSEngine*) -> QObject* { + QQmlEngine::setObjectOwnership(url_opener_instance, QQmlEngine::CppOwnership); + return url_opener_instance; + }); qmlRegisterSingletonType("org.bitcoincore.qt", 1, 0, "BitcoinUri", [](QQmlEngine*, QJSEngine*) -> QObject* { QQmlEngine::setObjectOwnership(bitcoin_uri_model_instance, QQmlEngine::CppOwnership); return bitcoin_uri_model_instance; @@ -480,8 +488,9 @@ int QmlGuiMain(int argc, char* argv[]) AppMode app_mode = SetupAppMode(); BuildInfo build_info; Clipboard clipboard; + UrlOpener url_opener; BitcoinUriModel bitcoin_uri_model; - RegisterQmlTypes(app_mode, build_info, clipboard, bitcoin_uri_model); + RegisterQmlTypes(app_mode, build_info, clipboard, url_opener, bitcoin_uri_model); const QString cli_lang = QString::fromStdString(gArgs.GetArg("-lang", "")); const QString startup_language = cli_lang.isEmpty() diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 9f9b24235d..6f294e0203 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -17,7 +17,6 @@ components/MempoolInformationRows.qml components/DeveloperOptions.qml components/ExternalSignerReviewActions.qml - components/ExternalPopup.qml components/FeeSelection.qml components/MiniBlockClock.qml components/MonospaceOutputView.qml @@ -34,7 +33,6 @@ components/ProxySettings.qml components/SettingsRestartNotice.qml components/StorageLocations.qml - components/Separator.qml components/StorageOptions.qml components/StorageSettings.qml components/ThemeSettings.qml @@ -66,6 +64,7 @@ controls/CoreTextField.qml controls/EditableKeyValueRow.qml controls/ExternalLink.qml + controls/ExternalPopup.qml controls/FocusBorder.qml controls/Header.qml controls/Icon.qml @@ -87,6 +86,7 @@ controls/ProxyLocationInput.qml controls/QRImage.qml controls/RightContentIcon.qml + controls/Separator.qml controls/qmldir controls/SendOptionsPopup.qml controls/SegmentedPicker.qml diff --git a/qml/components/AboutOptions.qml b/qml/components/AboutOptions.qml index 501dec59c9..a31069f09a 100644 --- a/qml/components/AboutOptions.qml +++ b/qml/components/AboutOptions.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2022 The Bitcoin Core developers +// Copyright (c) 2022-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -14,53 +14,58 @@ ColumnLayout { spacing: 0 Setting { id: websiteLink + objectName: "aboutWebsiteLink" Layout.fillWidth: true header: qsTr("Website") actionItem: ExternalLink { + objectName: "aboutWebsiteLinkIcon" parentState: websiteLink.visualState description: "bitcoincore.org" link: "https://bitcoincore.org" } - onClicked: openPopup(loadedItem.link) + onClicked: loadedItem.requestOpen() } Separator { Layout.fillWidth: true } Setting { id: sourceLink + objectName: "aboutSourceCodeLink" Layout.fillWidth: true header: qsTr("Source code") actionItem: ExternalLink { + objectName: "aboutSourceCodeLinkIcon" parentState: sourceLink.visualState description: "github.com/bitcoin/bitcoin" link: "https://github.com/bitcoin/bitcoin" } - onClicked: openPopup(loadedItem.link) + onClicked: loadedItem.requestOpen() } Separator { Layout.fillWidth: true } Setting { id: licenseLink + objectName: "aboutLicenseLink" Layout.fillWidth: true header: qsTr("License") actionItem: ExternalLink { + objectName: "aboutLicenseLinkIcon" parentState: licenseLink.visualState description: "MIT" link: "https://opensource.org/licenses/MIT" } - onClicked: openPopup(loadedItem.link) + onClicked: loadedItem.requestOpen() } Separator { Layout.fillWidth: true } Setting { id: versionLink + objectName: "aboutVersionLink" Layout.fillWidth: true header: qsTr("Version") actionItem: ExternalLink { + objectName: "aboutVersionLinkIcon" parentState: versionLink.visualState description: BuildInfo.fullClientVersion link: "https://bitcoin.org/en/download" - iconSource: "image://images/caret-right" - iconWidth: 18 - iconHeight: 18 } - onClicked: openPopup(loadedItem.link) + onClicked: loadedItem.requestOpen() } Separator { Layout.fillWidth: true } Setting { @@ -78,14 +83,4 @@ ColumnLayout { root.next() } } - ExternalPopup { - id: confirmPopup - anchors.centerIn: Overlay.overlay - width: parent.width - } - - function openPopup(link) { - confirmPopup.link = link - confirmPopup.open() - } } diff --git a/qml/components/ExternalPopup.qml b/qml/components/ExternalPopup.qml deleted file mode 100644 index 399b313129..0000000000 --- a/qml/components/ExternalPopup.qml +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2023 The Bitcoin Core developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. - -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import org.bitcoincore.qt 1.0 -import "../controls" - -Popup { - id: externalConfirmPopup - property string link: "" - modal: true - padding: 0 - anchors.centerIn: parent - - background: Rectangle { - color: Theme.color.background - radius: 10 - } - - ColumnLayout { - anchors.fill: parent - spacing: 0 - - CoreText { - Layout.fillWidth: true - Layout.preferredHeight: 55 - text: qsTr("External Link") - bold: true - font.pixelSize: 24 - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - - Separator { - Layout.fillWidth: true - } - - Header { - Layout.fillWidth: true - Layout.margins: 20 - Layout.topMargin: 20 - header: qsTr("Do you want to open the following website in your browser?") - headerBold: false - headerSize: 16 - description: ("\"" + externalConfirmPopup.link + "\"") - descriptionMargin: 8 - descriptionTextFormat: Text.PlainText - } - - GridLayout { - Layout.fillWidth: true - Layout.margins: 20 - Layout.topMargin: 0 - columns: AppMode.isDesktop ? 2 : 1 - columnSpacing: 15 - rowSpacing: 10 - - OutlineButton { - text: qsTr("Cancel") - Layout.fillWidth: true - Layout.minimumWidth: 120 - onClicked: externalConfirmPopup.close() - } - - ContinueButton { - text: qsTr("Ok") - Layout.fillWidth: true - Layout.minimumWidth: 120 - onClicked: { - Qt.openUrlExternally(externalConfirmPopup.link) - externalConfirmPopup.close() - } - } - } - } -} diff --git a/qml/components/ToastBanner.qml b/qml/components/ToastBanner.qml index 6edffb5f37..4b30c075a0 100644 --- a/qml/components/ToastBanner.qml +++ b/qml/components/ToastBanner.qml @@ -28,7 +28,15 @@ Rectangle { color: backgroundColor radius: 5 implicitHeight: Math.max(50, contentRow.implicitHeight + 20) - opacity: 0 + // Follows the initial visibility instead of always starting transparent. + // A banner whose condition already holds when it is built inside an + // already visible page never sees visible change, so the fade-in below + // would not run and the banner would keep its space in the layout while + // drawing nothing. Whether that happens depends on creation order (a page + // whose tree becomes visible only after construction still gets the + // change), so both orders must draw. The animations assign opacity + // directly, which drops this binding once one of them runs. + opacity: visible ? 1 : 0 onVisibleChanged: { if (visible) { @@ -43,6 +51,12 @@ Rectangle { } } + // Shown outright rather than faded in, so the auto-dismiss countdown that + // normally starts when the fade completes has to be started here instead. + Component.onCompleted: { + if (root.opacity === 1 && root.dismissAfter > 0) dismissTimer.start() + } + NumberAnimation { id: fadeInAnim target: root @@ -96,6 +110,11 @@ Rectangle { objectName: root.textObjectName Layout.fillWidth: true text: root.text + // Banner text is a message, never markup. Without this the Text + // default of AutoText would silently upgrade markup-looking + // strings (an error can interpolate a filesystem path) to rich + // text. + textFormat: Text.PlainText color: root.textColor font: Theme.text.description.font horizontalAlignment: root.iconSource != "" ? Text.AlignLeft : Text.AlignHCenter diff --git a/qml/controls/ExternalLink.qml b/qml/controls/ExternalLink.qml index 4837236ace..5d8c4000ad 100644 --- a/qml/controls/ExternalLink.qml +++ b/qml/controls/ExternalLink.qml @@ -1,10 +1,11 @@ -// Copyright (c) 2022 The Bitcoin Core developers +// Copyright (c) 2022-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 AbstractButton { id: root @@ -18,8 +19,24 @@ AbstractButton { property int iconSlotSize: 30 property color iconColor: Theme.color.neutral9 property color textColor: Theme.color.neutral9 + + // The confirmation dialog belongs to the link rather than to each page + // that hosts one. A page that forgot to wire it up would produce a link + // that silently does nothing, which is the failure this control exists to + // prevent. Derived from objectName so several links on one page stay + // individually addressable from tests. + readonly property string popupObjectName: root.objectName.length > 0 + ? root.objectName + "_popup" + : "externalLinkPopup" + enabled: root.parentState !== "DISABLED" - state: root.parentState + hoverEnabled: AppMode.isDesktop + state: root.enabled && root.hovered ? "HOVER" : root.parentState + + HoverHandler { + cursorShape: AppMode.isDesktop && root.enabled ? Qt.PointingHandCursor + : Qt.ArrowCursor + } states: [ State { @@ -76,5 +93,24 @@ AbstractButton { slotSize: root.iconSlotSize } } - onClicked: Qt.openUrlExternally(link) + // Opening always goes through ExternalPopup, which confirms the + // destination, checks whether the open succeeded, and offers a copy-URL + // fallback when it did not. + function requestOpen() { + popupLoader.active = true + popupLoader.item.link = root.link + popupLoader.item.open() + } + + onClicked: root.requestOpen() + + // Created on first use: most links are never clicked, and a popup per link + // on a page full of them is not worth instantiating up front. + Loader { + id: popupLoader + active: false + sourceComponent: ExternalPopup { + objectName: root.popupObjectName + } + } } diff --git a/qml/controls/ExternalPopup.qml b/qml/controls/ExternalPopup.qml new file mode 100644 index 0000000000..a2f1f5392c --- /dev/null +++ b/qml/controls/ExternalPopup.qml @@ -0,0 +1,208 @@ +// Copyright (c) 2023-2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 + +Popup { + id: externalConfirmPopup + objectName: "externalLinkPopup" + property string link: "" + + // Set once an open attempt has failed. Switches the popup to its error and + // copy-URL fallback layout instead of closing. + property bool openFailed: false + // Set while the copy confirmation is showing. The copy button is disabled + // for that window so a second click cannot go unacknowledged. + property bool linkCopied: false + + // How long the copy button stays in its "Copied" state before returning to + // "Copy link". Kept close to the app's other copy confirmation + // (ToastPopup.visibleDurationMs) so copy feedback feels the same. + property int copiedFeedbackMs: 2000 + + modal: true + // Without this the popup never enters the active focus chain, so Tab keeps + // cycling the page behind it instead of the dialog's own buttons. + focus: true + padding: 0 + + anchors.centerIn: Overlay.overlay + width: Math.min(420, (Overlay.overlay ? Overlay.overlay.width : 460) - 40) + + // Always start from the confirmation layout when (re)opened so a previous + // failure does not leak into the next link. + onAboutToShow: { + externalConfirmPopup.openFailed = false + externalConfirmPopup.linkCopied = false + copiedResetTimer.stop() + } + + // Try to open link in the user's browser. On success the popup closes; on + // failure it stays open and offers the copy-URL fallback. + function attemptOpen() { + if (UrlOpener.openUrl(externalConfirmPopup.link)) { + externalConfirmPopup.close() + } else { + externalConfirmPopup.openFailed = true + } + } + + function copyLink() { + Clipboard.setText(externalConfirmPopup.link) + externalConfirmPopup.linkCopied = true + copiedResetTimer.restart() + } + + // Entering the error state replaces the button the user just activated, so + // move focus onto its replacement: otherwise a keyboard user is left with + // no focused control and no signal that anything changed. + onOpenFailedChanged: { + if (externalConfirmPopup.openFailed) copyButton.forceActiveFocus() + } + + Timer { + id: copiedResetTimer + interval: externalConfirmPopup.copiedFeedbackMs + onTriggered: { + externalConfirmPopup.linkCopied = false + // The button was disabled while confirming, which drops focus, so + // hand it back rather than stranding the keyboard user. + if (externalConfirmPopup.opened && externalConfirmPopup.openFailed) { + copyButton.forceActiveFocus() + } + } + } + + background: Rectangle { + color: Theme.color.background + radius: 10 + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + CoreText { + Layout.fillWidth: true + Layout.preferredHeight: 55 + text: { + if (externalConfirmPopup.openFailed) { + //: Title of the dialog shown when an external link could not be opened. + return qsTr("Couldn't open link") + } + //: Title of the dialog asking to confirm opening an external link. + return qsTr("External Link") + } + bold: true + font.pixelSize: 24 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Separator { + Layout.fillWidth: true + } + + Header { + Layout.fillWidth: true + Layout.margins: 20 + Layout.topMargin: 20 + header: { + if (externalConfirmPopup.openFailed) { + //: Shown when an external link could not be handed to any application, prompting the user to copy it instead. + return qsTr("This link could not be opened. Copy it and open it manually.") + } + //: Confirmation question shown before opening a website in the user's browser. + return qsTr("Do you want to open the following website in your browser?") + } + headerBold: false + headerSize: 16 + description: "\"" + externalConfirmPopup.link + "\"" + descriptionMargin: 8 + descriptionTextFormat: Text.PlainText + // A URL carries an unbreakable run (a txid is 64 characters), which + // WordWrap cannot split, so it would spill past the dialog edge. + descriptionWrapMode: Text.Wrap + } + + // Confirmation buttons, shown before an open attempt. + GridLayout { + Layout.fillWidth: true + Layout.margins: 20 + Layout.topMargin: 0 + visible: !externalConfirmPopup.openFailed + columns: AppMode.isDesktop ? 2 : 1 + columnSpacing: 15 + rowSpacing: 10 + + OutlineButton { + objectName: "externalLinkCancel" + //: Button that dismisses the dialog without opening the external link. + text: qsTr("Cancel") + Accessible.role: Accessible.Button + Accessible.name: text + Layout.fillWidth: true + Layout.minimumWidth: 120 + onClicked: externalConfirmPopup.close() + } + + ContinueButton { + objectName: "externalLinkConfirm" + //: Button that confirms opening the external link in the browser. + text: qsTr("Ok") + Accessible.role: Accessible.Button + Accessible.name: text + Layout.fillWidth: true + Layout.minimumWidth: 120 + onClicked: externalConfirmPopup.attemptOpen() + } + } + + // Copy-URL fallback buttons, shown after an open attempt failed. + GridLayout { + Layout.fillWidth: true + Layout.margins: 20 + Layout.topMargin: 0 + visible: externalConfirmPopup.openFailed + columns: AppMode.isDesktop ? 2 : 1 + columnSpacing: 15 + rowSpacing: 10 + + OutlineButton { + objectName: "externalLinkClose" + //: Button that dismisses the external-link error dialog. + text: qsTr("Close") + Accessible.role: Accessible.Button + Accessible.name: text + Layout.fillWidth: true + Layout.minimumWidth: 120 + onClicked: externalConfirmPopup.close() + } + + ContinueButton { + id: copyButton + objectName: "externalLinkCopy" + text: { + if (externalConfirmPopup.linkCopied) { + //: Button label confirming an external link was copied to the clipboard. + return qsTr("Copied") + } + //: Button that copies an external link to the clipboard after it could not be opened. + return qsTr("Copy link") + } + // Disabled while confirming so the label always describes what + // the button will do, and a repeat copy is not silently a no-op. + enabled: !externalConfirmPopup.linkCopied + Accessible.role: Accessible.Button + Accessible.name: text + Layout.fillWidth: true + Layout.minimumWidth: 120 + onClicked: externalConfirmPopup.copyLink() + } + } + } +} diff --git a/qml/controls/Header.qml b/qml/controls/Header.qml index 2ce21ec71c..3c75202fa4 100644 --- a/qml/controls/Header.qml +++ b/qml/controls/Header.qml @@ -21,6 +21,9 @@ ColumnLayout { property string descriptionColor: Theme.color.neutral8 property bool descriptionBold: false property int descriptionTextFormat: Text.AutoText + // Override when the description can contain an unbreakable run, such as a + // URL carrying a txid, where WordWrap alone overflows the available width. + property int descriptionWrapMode: Text.WordWrap property string subtext: "" property int subtextMargin property int subtextSize: 15 @@ -61,7 +64,7 @@ ColumnLayout { text: root.description textFormat: root.descriptionTextFormat horizontalAlignment: root.center ? Text.AlignHCenter : Text.AlignLeft - wrapMode: wrap ? Text.WordWrap : Text.NoWrap + wrapMode: wrap ? root.descriptionWrapMode : Text.NoWrap Behavior on color { ColorAnimation { duration: 150 } diff --git a/qml/components/Separator.qml b/qml/controls/Separator.qml similarity index 95% rename from qml/components/Separator.qml rename to qml/controls/Separator.qml index 8e14bb9ff3..040cc29ada 100644 --- a/qml/components/Separator.qml +++ b/qml/controls/Separator.qml @@ -4,7 +4,6 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 -import "../controls" Rectangle { height: 1 diff --git a/qml/models/debuglogmodel.cpp b/qml/models/debuglogmodel.cpp index 6393611326..5ef1e455f2 100644 --- a/qml/models/debuglogmodel.cpp +++ b/qml/models/debuglogmodel.cpp @@ -41,6 +41,9 @@ QByteArray ReadAnchor(QFile& file, qint64 file_size) DebugLogModel::DebugLogModel(const fs::path& log_path, QObject* parent) : QAbstractListModel(parent) , m_log_path(log_path) + , m_open_local_file_fn([](const QString& path) { + return QDesktopServices::openUrl(QUrl::fromLocalFile(path)); + }) { m_reader = new QObject; m_reader_thread = new QThread(this); @@ -266,25 +269,32 @@ void DebugLogModel::loadMore() bool DebugLogModel::openLogFile() { const QString path_str = QString::fromStdString(m_log_path.utf8string()); - if (!fs::exists(m_log_path)) { - m_open_error = tr("Debug log file not found: %1").arg(path_str); - Q_EMIT openErrorChanged(); - return false; - } - const bool ok = QDesktopServices::openUrl(QUrl::fromLocalFile(path_str)); + // A missing log is a state of the log, not the outcome of this click, so + // it is left to the background reader to report and keep reporting. The + // UI disables this action while the log is unavailable; the check remains + // because the method is invokable from QML at any time. + if (!fs::exists(m_log_path)) return false; + const bool ok = m_open_local_file_fn(path_str); if (!ok) { - m_open_error = tr("Could not open debug log file. " - "No application is associated with this file type."); + m_external_open_error = tr("Could not open debug log file. " + "No application is associated with this file type."); Q_EMIT openErrorChanged(); return false; } - if (!m_open_error.isEmpty()) { - m_open_error.clear(); + if (!m_external_open_error.isEmpty()) { + m_external_open_error.clear(); Q_EMIT openErrorChanged(); } return true; } +void DebugLogModel::clearOpenError() +{ + if (m_external_open_error.isEmpty()) return; + m_external_open_error.clear(); + Q_EMIT openErrorChanged(); +} + void DebugLogModel::updateRelativeTimes() { if (!m_active || (m_all_lines.isEmpty() && m_display_lines.isEmpty())) return; @@ -637,6 +647,10 @@ void DebugLogModel::onReadCompleted(const ReadResult& result, if (!m_active || m_stopping) return; // Propagate open-error state from the background read. + if (m_log_available != result.file_opened) { + m_log_available = result.file_opened; + Q_EMIT logAvailableChanged(); + } if (!result.file_opened) { if (m_open_error != result.error_message) { m_open_error = result.error_message; diff --git a/qml/models/debuglogmodel.h b/qml/models/debuglogmodel.h index 6b45c12195..5dd742826e 100644 --- a/qml/models/debuglogmodel.h +++ b/qml/models/debuglogmodel.h @@ -15,6 +15,8 @@ #include #include +#include +#include class QThread; @@ -47,6 +49,7 @@ class DebugLogModel : public QAbstractListModel Q_PROPERTY(int loadLimit READ loadLimit WRITE setLoadLimit NOTIFY loadLimitChanged) Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged) Q_PROPERTY(QString openError READ openError NOTIFY openErrorChanged) + Q_PROPERTY(bool logAvailable READ logAvailable NOTIFY logAvailableChanged) public: enum Role { @@ -93,20 +96,50 @@ class DebugLogModel : public QAbstractListModel QString filter() const { return m_filter; } void setFilter(const QString& filter); - QString openError() const { return m_open_error; } + //! Two failures can be pending, and they have deliberately different + //! lifetimes. + //! + //! m_external_open_error is the outcome of a single user action (handing + //! the log to another application). It is transient: it clears on a later + //! successful openLogFile(), and on clearOpenError() when the page is + //! re-entered, so a stale click failure does not follow the user around. + //! + //! m_open_error describes the state of the log itself (missing or + //! unreadable). That state outlives any one visit, so it must survive + //! navigation and is owned solely by the background reader. + //! + //! The action error wins while it is set, because it answers the question + //! the user just asked. + QString openError() const { return m_external_open_error.isEmpty() ? m_open_error : m_external_open_error; } + + //! False while the log cannot be read. Controls that only make sense + //! against readable log content (searching it, opening it in another + //! application) are disabled on this. + bool logAvailable() const { return m_log_available; } Q_INVOKABLE void refresh(bool full_load = false); Q_INVOKABLE void loadMore(); Q_INVOKABLE bool openLogFile(); + //! Clear the external-open error. Called when re-entering the page so a + //! stale openLogFile() failure does not persist across navigation; the + //! banner reflects the last open attempt, not the log content. + Q_INVOKABLE void clearOpenError(); Q_INVOKABLE void updateRelativeTimes(); void stop(); + using OpenLocalFileFn = std::function; + //! Replaces the hand-off to the desktop's file association, so tests can + //! drive the "no application is associated" failure without depending on + //! the machine having (or not having) a handler for .log files. + void setOpenLocalFileFnForTesting(OpenLocalFileFn fn) { m_open_local_file_fn = std::move(fn); } + Q_SIGNALS: void hasMoreLinesChanged(); void activeChanged(); void loadLimitChanged(); void filterChanged(); void openErrorChanged(); + void logAvailableChanged(); //! Emitted when new lines are prepended at the top during an auto-refresh. void newLinesAdded(int count); @@ -209,6 +242,16 @@ class DebugLogModel : public QAbstractListModel //! so empty/short logs and interrupted loadMore requests are unambiguous. int m_loaded_limit{0}; QString m_open_error; + //! Failure from openLogFile() (open the log in an external application), + //! kept separate from m_open_error, which the background reader owns and + //! clears on any successful read. + QString m_external_open_error; + + //! Whether the last background read could open the log. Starts true so the + //! page does not flash a disabled state before the first read lands. + bool m_log_available{true}; + + OpenLocalFileFn m_open_local_file_fn; //! End-of-file state from the last successful worker read. Normal //! refreshes validate the anchor, seek to file_size, and parse only bytes diff --git a/qml/pages/settings/SettingsDebugLog.qml b/qml/pages/settings/SettingsDebugLog.qml index ea18a082df..0b243daf01 100644 --- a/qml/pages/settings/SettingsDebugLog.qml +++ b/qml/pages/settings/SettingsDebugLog.qml @@ -58,6 +58,9 @@ Page { AbstractButton { id: exportBtn objectName: "debugLogExportButton" + // Nothing to hand to another application while the log cannot + // be read. + enabled: debugLogModel.logAvailable implicitWidth: 52 implicitHeight: 52 hoverEnabled: true @@ -67,8 +70,8 @@ Page { background: Rectangle { radius: 5 - color: exportBtn.hovered ? Theme.color.neutral2 - : Theme.color.background + color: exportBtn.hovered && exportBtn.enabled ? Theme.color.neutral2 + : Theme.color.background Behavior on color { ColorAnimation { duration: 150 } } } @@ -76,14 +79,16 @@ Page { Icon { anchors.centerIn: parent source: "image://images/export" - color: Theme.color.neutral9 + color: exportBtn.enabled ? Theme.color.neutral9 : Theme.color.neutral4 size: 28 } } onClicked: debugLogModel.openLogFile() - HoverHandler { cursorShape: Qt.PointingHandCursor } + HoverHandler { + cursorShape: exportBtn.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + } } } } @@ -101,6 +106,20 @@ Page { } spacing: 0 + // Matches the Sign/Verify message result banner rather than a bare + // line of red text. Clears on a later successful open, or when the + // page is re-entered (Component.onCompleted below). + ToastBanner { + objectName: "debugLogOpenErrorBanner" + Layout.fillWidth: true + Layout.bottomMargin: visible ? 10 : 0 + visible: debugLogModel.openError.length > 0 + backgroundColor: Theme.color.red + iconSource: "image://images/info-filled" + text: debugLogModel.openError + textObjectName: "debugLogOpenErrorText" + } + RowLayout { id: searchRow objectName: "debugLogSearchRow" @@ -122,6 +141,10 @@ Page { TextField { id: searchField objectName: "debugLogSearchField" + // Nothing to search while the log cannot be read. The refresh + // button stays enabled: it is how the user recovers once the + // file exists again. + enabled: debugLogModel.logAvailable Layout.fillWidth: true Layout.preferredHeight: 44 leftPadding: 0 @@ -129,7 +152,7 @@ Page { topPadding: 0 bottomPadding: 0 font: Theme.text.description.font - color: Theme.color.neutral9 + color: searchField.enabled ? Theme.color.neutral9 : Theme.color.neutral4 placeholderTextColor: Theme.color.neutral5 placeholderText: qsTr("Search...") // The page is unloaded whenever another Settings section is @@ -328,6 +351,11 @@ Page { } } - Component.onCompleted: debugLogModel.active = true + Component.onCompleted: { + // A stale open failure from a previous visit is about that visit's + // open attempt; start the page without it. + debugLogModel.clearOpenError() + debugLogModel.active = true + } Component.onDestruction: debugLogModel.active = false } diff --git a/qml/pages/wallet/ActivityDetails.qml b/qml/pages/wallet/ActivityDetails.qml index 5e3a5ee42c..9cd1244d34 100644 --- a/qml/pages/wallet/ActivityDetails.qml +++ b/qml/pages/wallet/ActivityDetails.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2024-2025 The Bitcoin Core developers +// Copyright (c) 2024-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -155,8 +155,14 @@ Page { Repeater { model: optionsModel.thirdPartyTransactionLinks(root.txid) delegate: ExternalLink { + required property int index required property var modelData - width: thirdPartyLinks.width + objectName: "activityDetailsThirdPartyLink_" + index + // Sized to its own content and centred, so the icon + // sits next to the label instead of being pushed to + // the far edge, and the clickable area is the link + // rather than the full column width. + anchors.horizontalCenter: parent.horizontalCenter parentState: "FILLED" description: qsTr("Show in %1").arg(modelData.host) link: modelData.url diff --git a/qml/urlopener.h b/qml/urlopener.h new file mode 100644 index 0000000000..7dc83da596 --- /dev/null +++ b/qml/urlopener.h @@ -0,0 +1,61 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_URLOPENER_H +#define BITCOIN_QML_URLOPENER_H + +#include +#include +#include +#include + +#include +#include + +//! Hands an external URL to the operating system's default handler. +//! +//! This is the single point where the app leaves for an external application, +//! so the scheme allowlist lives here rather than in QML: every URL that +//! reaches it is either a compile-time constant or built from the +//! user-configured third-party transaction template, and refusing anything +//! outside http/https keeps a future caller from inheriting an unchecked +//! handoff to the OS handler registry. +//! +//! The open call itself is injectable so tests can drive both outcomes without +//! launching a real browser, which keeps the production QML on one +//! unconditional path. +class UrlOpener : public QObject +{ + Q_OBJECT + +public: + using OpenUrlFn = std::function; + + explicit UrlOpener(QObject* parent = nullptr) + : QObject(parent) + , m_open_url_fn([](const QUrl& url) { return QDesktopServices::openUrl(url); }) + { + } + + //! Returns false when the URL is malformed, uses a scheme other than + //! http/https, or no handler could be launched for it. + //! + //! Note that a launched handler that later fails still reports success: + //! the platform only tells us whether a handler was started. + Q_INVOKABLE bool openUrl(const QString& url) + { + const QUrl parsed(url, QUrl::StrictMode); + if (!parsed.isValid() || parsed.host().isEmpty()) return false; + const QString scheme = parsed.scheme().toLower(); + if (scheme != QStringLiteral("http") && scheme != QStringLiteral("https")) return false; + return m_open_url_fn(parsed); + } + + void setOpenUrlFnForTesting(OpenUrlFn fn) { m_open_url_fn = std::move(fn); } + +private: + OpenUrlFn m_open_url_fn; +}; + +#endif // BITCOIN_QML_URLOPENER_H diff --git a/test/functional/qml_test_debug_log.py b/test/functional/qml_test_debug_log.py index a5e8d747b2..ea2908efce 100644 --- a/test/functional/qml_test_debug_log.py +++ b/test/functional/qml_test_debug_log.py @@ -42,14 +42,20 @@ def assert_close(actual, expected, label, tolerance=1): class DebugLogHarness: """Launches the GUI node as an onboarded profile on NodeRunner.""" - def __init__(self): + def __init__(self, seed_history=True): self.gui_binary = find_gui_binary() self.tmpdir = tempfile.mkdtemp(prefix="qml_test_debug_log_") self.socket_path = os.path.join(self.tmpdir, "test_bridge.sock") self.process = None self.driver = None + self.seed_history = seed_history self.datadir = setup_datadir(self.tmpdir) - self._seed_debug_log_history() + if seed_history: + self._seed_debug_log_history() + + @property + def log_path(self): + return os.path.join(self.datadir, "regtest", "debug.log") def _seed_debug_log_history(self): """Create enough history to exercise the initial cap and Load more.""" @@ -80,6 +86,10 @@ def start(self): "-debugexclude=leveldb", "-nolisten", ] + if not self.seed_history: + # Keep the log from being created, so the viewer has to cope with + # a log it cannot read. + args.append("-nodebuglogfile") print(f"Starting GUI: {' '.join(args)}") self.process = subprocess.Popen( args, env=env, @@ -436,6 +446,63 @@ def test_search_filter(gui, total_count): # ── Entry point ─────────────────────────────────────────────────────────────── +def test_unreadable_log(gui, harness): + """The viewer has to stay usable, and honest, with no readable log.""" + print("\n── test_unreadable_log ───────────────────────────────────────────") + assert not os.path.exists(harness.log_path), "expected no debug.log on disk" + + banner = "debugLogOpenErrorBanner" + gui.wait_for_property(banner, "opacity", 1, timeout_ms=10000) + message = gui.get_property("debugLogOpenErrorText", "text") + assert "not found" in message, f"unexpected banner text: {message!r}" + print(f" banner shown: {message}") + + # Searching and opening the log elsewhere are meaningless without content. + assert gui.get_property("debugLogSearchField", "enabled") is False, \ + "search field should be disabled while the log is unreadable" + assert gui.get_property("debugLogExportButton", "enabled") is False, \ + "export button should be disabled while the log is unreadable" + # Refresh stays available: it is how the user recovers. + assert gui.get_property("debugLogRefreshButton", "enabled") is True, \ + "refresh should stay enabled while the log is unreadable" + print(" PASSED: search and export disabled, refresh still enabled") + + # The log being missing is a state, not the outcome of one click, so it has + # to survive leaving the page. The banner is faded in from a visibility + # change, so a page rebuilt with the failure already pending is the case + # that can silently render it transparent. + gui.click("settings_display") + gui.settle() + gui.click("settings_debuglog") + gui.wait_for_page("settingsDebugLog", timeout_ms=10000) + gui.wait_for_property(banner, "opacity", 1, timeout_ms=10000) + assert gui.get_property("debugLogSearchField", "enabled") is False, \ + "search field should still be disabled after re-entering the page" + print(" PASSED: banner survives leaving and re-entering the page") + + # Once the log exists the page recovers on the next read. + os.makedirs(os.path.dirname(harness.log_path), exist_ok=True) + with open(harness.log_path, "w", encoding="utf-8") as f: + f.write("2026-01-01T00:00:00Z test-automation recovered\n") + gui.click("debugLogRefreshButton") + gui.wait_for_property(banner, "opacity", 0, timeout_ms=10000) + gui.wait_for_property("debugLogSearchField", "enabled", True, timeout_ms=10000) + assert gui.get_property("debugLogExportButton", "enabled") is True, \ + "export button should be re-enabled once the log is readable" + print(" PASSED: banner clears and controls re-enable once the log exists") + + +def run_unreadable_log_tests(): + harness = DebugLogHarness(seed_history=False) + try: + harness.start() + gui = harness.driver + navigate_to_debug_log(gui) + test_unreadable_log(gui, harness) + finally: + harness.stop() + + def run_tests(): harness = DebugLogHarness() try: @@ -456,6 +523,8 @@ def run_tests(): total = test_load_more_at_bottom(gui, total) test_close_settings(gui) + run_unreadable_log_tests() + print("\n" + "=" * 50) print("All debug log tests PASSED") print("=" * 50) diff --git a/test/functional/qml_test_external_link_confirm.py b/test/functional/qml_test_external_link_confirm.py new file mode 100755 index 0000000000..7f434f2875 --- /dev/null +++ b/test/functional/qml_test_external_link_confirm.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test the About-page external-link confirmation popup via the test bridge. + +Reaches the About page from the onboarding cover (no node-startup wait), +opens the external-link confirmation popup from a link row, and verifies that +cancelling closes it without navigating away. The "Ok" button is never +clicked on purpose: confirming would hand the URL to a real handler and try to +launch a browser. The open success and failure paths, the scheme rejection, +and the copy-URL fallback are covered deterministically by the QML unit test +(test/qml/tst_externalpopup.qml), which drives a stubbed UrlOpener. + +This test requires the binary to be built with -DENABLE_TEST_AUTOMATION=ON. +""" + +import sys + +from qml_test_harness import QmlTestHarness, dump_qml_tree, parse_args + +# Each ExternalLink owns its confirmation popup and names it after itself. +WEBSITE_POPUP = "aboutWebsiteLinkIcon_popup" +SOURCE_POPUP = "aboutSourceCodeLinkIcon_popup" + + +def open_link_popup(gui, link_object_name, popup_object_name): + """Click a link row and wait for its confirmation popup to open.""" + gui.click(link_object_name) + gui.wait_for_object(popup_object_name, timeout_ms=5000) + gui.wait_for_property(popup_object_name, "opened", True, timeout_ms=5000) + + +def run_tests(): + args = parse_args() + harness = QmlTestHarness( + socket_path=args.socket_path, + reset_settings=not bool(args.socket_path), + start_onboarded=False, + use_datadir_arg=bool(args.socket_path), + extra_args=[] if args.socket_path else ["-regtest"], + ) + gui = None + try: + harness.start() + gui = harness.driver + + # The app starts fresh (-resetguisettings), so we land on the + # pre-init onboarding cover window. get_current_page() reads the + # runtime shell's page stack, which does not exist yet in the + # pre-init window, so wait on the cover page directly. + print("Wait for the pre-init onboarding cover ...") + gui.wait_for_page("onboardingCover", timeout_ms=10000) + + # The info button on the cover opens the About page. + print("Open About page from onboarding cover ...") + gui.wait_for_object("onboardingCoverInfoButton", timeout_ms=10000) + gui.click("onboardingCoverInfoButton") + gui.wait_for_page("settingsAbout", timeout_ms=10000) + + # Opening a link row shows the confirmation popup. + print("Open confirmation popup from the Website link ...") + open_link_popup(gui, "aboutWebsiteLink", WEBSITE_POPUP) + + # The popup starts in its confirmation state: both Cancel and Ok are + # shown, the error/copy fallback is not. + assert gui.get_property("externalLinkCancel", "visible") is True, \ + "Cancel button should be visible in the confirmation state" + assert gui.get_property("externalLinkConfirm", "visible") is True, \ + "Ok button should be visible in the confirmation state" + + # Cancelling closes the popup without leaving the About page. The About + # page is a sub-page of the onboarding cover's internal stack, so check + # its visibility directly rather than get_current_page(), which reports + # the outer onboarding page. + print("Cancel the popup ...") + gui.click("externalLinkCancel") + gui.wait_for_property(WEBSITE_POPUP, "opened", False, timeout_ms=5000) + assert gui.get_property("settingsAbout", "visible") is True, \ + "Expected to remain on the About page after cancelling" + + # The popup is reusable: a second link reopens it and cancels cleanly. + print("Reopen the popup from the Source code link and cancel ...") + open_link_popup(gui, "aboutSourceCodeLink", SOURCE_POPUP) + gui.click("externalLinkCancel") + gui.wait_for_property(SOURCE_POPUP, "opened", False, timeout_ms=5000) + + print("\n" + "=" * 50) + print("All tests PASSED") + print("=" * 50) + + except Exception as e: + print(f"\nFAILED: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + if gui is not None: + dump_qml_tree(gui) + sys.exit(1) + finally: + harness.stop() + + +if __name__ == '__main__': + run_tests() diff --git a/test/qml/bitcoin_qmltests.qrc b/test/qml/bitcoin_qmltests.qrc index 4ffdd045ea..e5087f3547 100644 --- a/test/qml/bitcoin_qmltests.qrc +++ b/test/qml/bitcoin_qmltests.qrc @@ -1,5 +1,6 @@ + tst_aboutoptions.qml tst_activitydetails.qml tst_activity.qml tst_address_components.qml @@ -14,10 +15,12 @@ tst_createpassword.qml tst_createtypeselector.qml tst_createwalletwizard.qml + tst_debuglog.qml tst_debuglogoutputview.qml tst_desktopwallets.qml tst_displaysettings.qml tst_dropdownbutton.qml + tst_externalpopup.qml tst_externalsignerreviewactions.qml tst_feeselection.qml tst_mainrouting.qml diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index 0fef97273c..c6212de79a 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -3085,6 +3086,7 @@ class MockDebugLogModel : public QAbstractListModel Q_PROPERTY(bool hasMoreLines READ hasMoreLines NOTIFY hasMoreLinesChanged) Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged) Q_PROPERTY(QString openError READ openError NOTIFY openErrorChanged) + Q_PROPERTY(bool logAvailable READ logAvailable NOTIFY logAvailableChanged) Q_PROPERTY(int count READ count NOTIFY countChanged) Q_PROPERTY(int loadMoreCalls READ loadMoreCalls NOTIFY loadMoreCallsChanged) @@ -3159,7 +3161,14 @@ class MockDebugLogModel : public QAbstractListModel m_filter = filter; Q_EMIT filterChanged(); } - QString openError() const { return {}; } + QString openError() const { return m_open_error; } + bool logAvailable() const { return m_log_available; } + Q_INVOKABLE void setLogAvailable(bool available) + { + if (m_log_available == available) return; + m_log_available = available; + Q_EMIT logAvailableChanged(); + } int loadMoreCalls() const { return m_load_more_calls; } Q_INVOKABLE void refresh(bool = false) {} @@ -3170,7 +3179,36 @@ class MockDebugLogModel : public QAbstractListModel appendRowsForTest(20); setHasMoreLinesForTest(false); } - Q_INVOKABLE bool openLogFile() { return true; } + Q_INVOKABLE bool openLogFile() + { + const QString next = m_open_result ? QString{} : m_pending_error; + if (m_open_error != next) { + m_open_error = next; + Q_EMIT openErrorChanged(); + } + return m_open_result; + } + Q_INVOKABLE void setOpenLogFileResult(bool ok, const QString& error) + { + m_open_result = ok; + m_pending_error = error; + } + Q_INVOKABLE void clearOpenError() + { + if (m_open_error.isEmpty()) return; + m_open_error.clear(); + Q_EMIT openErrorChanged(); + } + Q_INVOKABLE void reset() + { + m_open_result = true; + m_pending_error.clear(); + setLogAvailable(true); + if (!m_open_error.isEmpty()) { + m_open_error.clear(); + Q_EMIT openErrorChanged(); + } + } Q_INVOKABLE void updateRelativeTimes() {} Q_INVOKABLE void resetForTest(int count, bool has_more_lines) @@ -3279,6 +3317,7 @@ class MockDebugLogModel : public QAbstractListModel void hasMoreLinesChanged(); void filterChanged(); void openErrorChanged(); + void logAvailableChanged(); void newLinesAdded(int count); void countChanged(); void loadMoreCallsChanged(); @@ -3308,6 +3347,52 @@ class MockDebugLogModel : public QAbstractListModel int m_load_more_calls{0}; int m_next_new_row{0}; int m_next_old_row{0}; + QString m_open_error; + QString m_pending_error; + bool m_open_result{true}; + bool m_log_available{true}; +}; + +class MockClipboard : public QObject +{ + Q_OBJECT + +public: + Q_INVOKABLE void setText(const QString& text) { m_text = text; } + Q_INVOKABLE QString text() const { return m_text; } + +private: + QString m_text; +}; + +//! Stands in for the real UrlOpener so the popup's success and failure paths +//! can be driven without launching a browser. Mirrors the production scheme +//! allowlist so a test cannot pass on a URL the real opener would reject. +class MockUrlOpener : public QObject +{ + Q_OBJECT + +public: + Q_INVOKABLE bool openUrl(const QString& url) + { + m_last_url = url; + const QUrl parsed(url, QUrl::StrictMode); + if (!parsed.isValid() || parsed.host().isEmpty()) return false; + const QString scheme = parsed.scheme().toLower(); + if (scheme != QStringLiteral("http") && scheme != QStringLiteral("https")) return false; + return m_open_result; + } + Q_INVOKABLE void setOpenResult(bool ok) { m_open_result = ok; } + Q_INVOKABLE QString lastUrl() const { return m_last_url; } + Q_INVOKABLE void reset() + { + m_open_result = true; + m_last_url.clear(); + } + +private: + bool m_open_result{true}; + QString m_last_url; }; class QmlTestsSetup : public QObject @@ -3341,6 +3426,8 @@ public Q_SLOTS: static MockBumpTransactionModel bump_model; static MockDesktopWindowBehaviorModel desktop_window_behavior_model; static MockDebugLogModel debug_log_model; + static MockClipboard clipboard; + static MockUrlOpener url_opener; recipients_model.setCurrent(&send_recipient); wallet_model.setActivityListModel(&activity_list_model); wallet_model.setBumpModel(&bump_model); @@ -3351,6 +3438,8 @@ public Q_SLOTS: wallet_controller.setSelectedWalletObject(&wallet_model); qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "AppMode", &app_mode); qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "BuildInfo", &build_info); + qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "Clipboard", &clipboard); + qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "UrlOpener", &url_opener); qmlRegisterUncreatableType( "org.bitcoincore.qt", 1, @@ -3387,6 +3476,8 @@ public Q_SLOTS: engine->rootContext()->setContextProperty(QStringLiteral("optionsModel"), &options_model); engine->rootContext()->setContextProperty(QStringLiteral("chainModel"), &chain_model); engine->rootContext()->setContextProperty(QStringLiteral("nodeModel"), &node_model); + engine->rootContext()->setContextProperty(QStringLiteral("debugLogModel"), &debug_log_model); + engine->rootContext()->setContextProperty(QStringLiteral("testUrlOpener"), &url_opener); engine->rootContext()->setContextProperty(QStringLiteral("peerTableModel"), &peer_table_model); engine->rootContext()->setContextProperty(QStringLiteral("networkTrafficTower"), &network_traffic_tower); engine->rootContext()->setContextProperty(QStringLiteral("testNetworkTrafficTower"), &network_traffic_tower); @@ -3406,7 +3497,6 @@ public Q_SLOTS: engine->rootContext()->setContextProperty(QStringLiteral("testCoinsListModel"), &coins_list_model); engine->rootContext()->setContextProperty(QStringLiteral("testBumpModel"), &bump_model); engine->rootContext()->setContextProperty(QStringLiteral("desktopWindowBehaviorModel"), &desktop_window_behavior_model); - engine->rootContext()->setContextProperty(QStringLiteral("debugLogModel"), &debug_log_model); engine->rootContext()->setContextProperty(QStringLiteral("testDebugLogModel"), &debug_log_model); engine->addImportPath(QStringLiteral(BITCOINQML_QML_SOURCE_DIR)); } diff --git a/test/qml/tst_aboutoptions.qml b/test/qml/tst_aboutoptions.qml new file mode 100644 index 0000000000..9ce8ac71f2 --- /dev/null +++ b/test/qml/tst_aboutoptions.qml @@ -0,0 +1,62 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtTest 1.2 +import "../../qml/components" + +TestCase { + name: "AboutOptions" + when: windowShown + width: 520 + height: 600 + + Component { + id: aboutOptionsComponent + + AboutOptions { + width: 480 + } + } + + function createOptions() { + const options = createTemporaryObject(aboutOptionsComponent, this) + verify(options !== null) + return options + } + + // The link owns its confirmation popup, so clicking the icon (the + // ExternalLink itself, not the surrounding Setting row) opens the dialog + // rather than the URL. The popup does not exist until first use. + function test_link_icon_routes_through_confirmation_popup() { + const options = createOptions() + const linkIcon = findChild(options, "aboutWebsiteLinkIcon") + verify(linkIcon !== null) + compare(linkIcon.popupObjectName, "aboutWebsiteLinkIcon_popup") + // The link owns its dialog, so assert against the link's own subtree. + verify(findChild(linkIcon, linkIcon.popupObjectName) === null) + + linkIcon.clicked() + + const popup = findChild(linkIcon, linkIcon.popupObjectName) + verify(popup !== null) + tryCompare(popup, "opened", true) + compare(popup.link, "https://bitcoincore.org") + } + + // The surrounding row is clickable too, and must reach the same dialog as + // the icon rather than carrying its own copy of the wiring. + function test_row_click_opens_the_links_own_popup() { + const options = createOptions() + const row = findChild(options, "aboutSourceCodeLink") + verify(row !== null) + + row.clicked() + + const popup = findChild(row, "aboutSourceCodeLinkIcon_popup") + verify(popup !== null) + tryCompare(popup, "opened", true) + compare(popup.link, "https://github.com/bitcoin/bitcoin") + } +} diff --git a/test/qml/tst_activitydetails.qml b/test/qml/tst_activitydetails.qml index 085c4902f9..f895cd3479 100644 --- a/test/qml/tst_activitydetails.qml +++ b/test/qml/tst_activitydetails.qml @@ -239,6 +239,39 @@ TestCase { }) } + // Third-party links must route through the confirmation popup rather than + // opening the URL directly, so the user sees the destination before + // leaving the app. + function test_thirdPartyTransactionLink_routes_through_confirmation_popup() { + optionsModel.thirdPartyTransactionUrls = "https://example.com/tx/%s" + const page = createTemporaryObject(detailsComponent, this, { + txid: "ffff", + canBump: false, + amount: "+0.01000000 BTC", + date: "2026-01-06", + depth: 1, + status: 2, + type: 1, + address: "bcrt1qrequestaddress" + }) + verify(page !== null) + + tryVerify(function() { + return findChild(page, "activityDetailsThirdPartyLink_0") !== null + }) + const link = findChild(page, "activityDetailsThirdPartyLink_0") + compare(link.popupObjectName, "activityDetailsThirdPartyLink_0_popup") + // The link owns its dialog, so assert against the link's own subtree. + verify(findChild(link, link.popupObjectName) === null) + + link.clicked() + + const popup = findChild(link, link.popupObjectName) + verify(popup !== null) + tryCompare(popup, "opened", true) + compare(popup.link, "https://example.com/tx/ffff") + } + function test_no_thirdPartyTransactionLinks_hides_section() { const page = createTemporaryObject(detailsComponent, this, { txid: "gggg", diff --git a/test/qml/tst_debuglog.qml b/test/qml/tst_debuglog.qml new file mode 100644 index 0000000000..09d9c1b125 --- /dev/null +++ b/test/qml/tst_debuglog.qml @@ -0,0 +1,108 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtTest 1.2 +import "../../qml/pages/settings" + +TestCase { + name: "DebugLog" + when: windowShown + width: 520 + height: 720 + + Component { + id: debugLogComponent + + SettingsDebugLog { + width: 480 + height: 680 + } + } + + function init() { + debugLogModel.reset() + } + + function createPage() { + const page = createTemporaryObject(debugLogComponent, this) + verify(page !== null) + return page + } + + // A failed open surfaces the model's error message on the page. The label + // is hidden (text bound to the empty openError) until the open fails. + function test_open_failure_shows_error() { + const page = createPage() + const errorText = findChild(page, "debugLogOpenErrorText") + verify(errorText !== null) + compare(errorText.text, "") + + debugLogModel.setOpenLogFileResult(false, "Could not open debug log file.") + debugLogModel.openLogFile() + + compare(errorText.text, "Could not open debug log file.") + } + + // A later successful open clears the previously shown error. + function test_successful_open_clears_error() { + const page = createPage() + const errorText = findChild(page, "debugLogOpenErrorText") + verify(errorText !== null) + + debugLogModel.setOpenLogFileResult(false, "Could not open debug log file.") + debugLogModel.openLogFile() + compare(errorText.text, "Could not open debug log file.") + + debugLogModel.setOpenLogFileResult(true, "") + debugLogModel.openLogFile() + + compare(errorText.text, "") + } + + // Searching and handing the log to another application both need readable + // log content, so they are disabled while there is none. + function test_unreadable_log_disables_search_and_export() { + const page = createPage() + const search = findChild(page, "debugLogSearchField") + const exportButton = findChild(page, "debugLogExportButton") + const refresh = findChild(page, "debugLogRefreshButton") + verify(search !== null) + verify(exportButton !== null) + verify(refresh !== null) + verify(search.enabled) + verify(exportButton.enabled) + + debugLogModel.setLogAvailable(false) + + compare(search.enabled, false) + compare(exportButton.enabled, false) + // Refresh stays available: it is how the user recovers once the log + // exists again. + compare(refresh.enabled, true) + + debugLogModel.setLogAvailable(true) + + compare(search.enabled, true) + compare(exportButton.enabled, true) + } + + // The open error is about the last open attempt on this visit, so it must + // not survive navigation: a freshly created page clears any stale error + // left in the long-lived model. + function test_reentering_page_clears_stale_error() { + const firstPage = createPage() + debugLogModel.setOpenLogFileResult(false, "Could not open debug log file.") + debugLogModel.openLogFile() + compare(findChild(firstPage, "debugLogOpenErrorText").text, + "Could not open debug log file.") + firstPage.destroy() + wait(0) + + const secondPage = createPage() + const errorText = findChild(secondPage, "debugLogOpenErrorText") + verify(errorText !== null) + compare(errorText.text, "") + } +} diff --git a/test/qml/tst_externalpopup.qml b/test/qml/tst_externalpopup.qml new file mode 100644 index 0000000000..17325508fd --- /dev/null +++ b/test/qml/tst_externalpopup.qml @@ -0,0 +1,175 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtTest 1.2 +import org.bitcoincore.qt 1.0 +import "../../qml/controls" + +TestCase { + name: "ExternalPopup" + when: windowShown + width: 500 + height: 400 + + Component { + id: popupComponent + + ExternalPopup { + link: "https://bitcoincore.org" + width: 400 + height: 300 + } + } + + function findObjectByName(root, objectName) { + if (!root) { + return null + } + if (root.objectName === objectName) { + return root + } + + if (root.contentItem) { + const contentResult = findObjectByName(root.contentItem, objectName) + if (contentResult) { + return contentResult + } + } + + const children = root.children || [] + for (let i = 0; i < children.length; ++i) { + const childResult = findObjectByName(children[i], objectName) + if (childResult) { + return childResult + } + } + + return null + } + + function init() { + UrlOpener.reset() + Clipboard.setText("") + } + + function createPopup() { + const popup = createTemporaryObject(popupComponent, this) + verify(popup !== null) + return popup + } + + // A successful open closes the popup and never enters the error state. + function test_successful_open_closes_popup() { + const popup = createPopup() + UrlOpener.setOpenResult(true) + popup.open() + tryCompare(popup, "opened", true) + + const ok = findObjectByName(popup, "externalLinkConfirm") + verify(ok !== null) + mouseClick(ok, ok.width / 2, ok.height / 2) + + tryCompare(popup, "opened", false) + verify(!popup.openFailed) + } + + // A failed open keeps the popup open and reveals the copy-URL fallback. + function test_failed_open_shows_copy_fallback() { + const popup = createPopup() + UrlOpener.setOpenResult(false) + popup.open() + tryCompare(popup, "opened", true) + + const ok = findObjectByName(popup, "externalLinkConfirm") + verify(ok !== null) + mouseClick(ok, ok.width / 2, ok.height / 2) + + verify(popup.openFailed) + compare(popup.opened, true) + + const copy = findObjectByName(popup, "externalLinkCopy") + verify(copy !== null) + verify(copy.visible) + } + + // The copy fallback writes the link to the clipboard. + function test_copy_fallback_copies_link() { + const popup = createPopup() + UrlOpener.setOpenResult(false) + popup.open() + tryCompare(popup, "opened", true) + popup.attemptOpen() + verify(popup.openFailed) + + const copy = findObjectByName(popup, "externalLinkCopy") + verify(copy !== null) + verify(copy.visible) + mouseClick(copy, copy.width / 2, copy.height / 2) + + verify(popup.linkCopied) + compare(Clipboard.text(), "https://bitcoincore.org") + } + + // A scheme outside http/https is refused before anything is handed to the + // OS, and is reported to the user as an ordinary open failure so the + // copy-URL fallback is still offered. + function test_non_http_scheme_is_refused() { + const popup = createPopup() + UrlOpener.setOpenResult(true) + popup.link = "file:///etc/passwd" + popup.open() + tryCompare(popup, "opened", true) + + popup.attemptOpen() + + verify(popup.openFailed) + compare(popup.opened, true) + } + + // Copying disables the button for the duration of the confirmation so the + // label always describes what the button will do, then restores it. + function test_copy_button_disables_then_restores() { + const popup = createPopup() + UrlOpener.setOpenResult(false) + popup.copiedFeedbackMs = 120 + popup.open() + tryCompare(popup, "opened", true) + popup.attemptOpen() + verify(popup.openFailed) + + const copy = findObjectByName(popup, "externalLinkCopy") + verify(copy !== null) + const label = copy.text + verify(copy.enabled) + + popup.copyLink() + + verify(popup.linkCopied) + compare(copy.enabled, false) + verify(copy.text !== label) + + tryCompare(popup, "linkCopied", false) + compare(copy.enabled, true) + compare(copy.text, label) + } + + // Reopening after a failure resets back to the confirmation state. + function test_reopen_resets_failure_state() { + const popup = createPopup() + UrlOpener.setOpenResult(false) + popup.open() + tryCompare(popup, "opened", true) + popup.attemptOpen() + verify(popup.openFailed) + + popup.close() + tryCompare(popup, "opened", false) + + popup.open() + tryCompare(popup, "opened", true) + verify(!popup.openFailed) + verify(!popup.linkCopied) + } +} diff --git a/test/qml/tst_rightcontenticon.qml b/test/qml/tst_rightcontenticon.qml index c37e98ad1f..67b84cf40a 100644 --- a/test/qml/tst_rightcontenticon.qml +++ b/test/qml/tst_rightcontenticon.qml @@ -126,6 +126,38 @@ TestCase { compare(slot.iconSize, 18) } + // Hover event delivery is unreliable under the offscreen platform (see the + // note in tst_setting.qml), so the positive path accepts either state; the + // strict contract is that hover must never override FILLED with anything + // other than HOVER, and must revert once the mouse leaves. + function test_external_link_hover_switches_to_hover_state() { + const item = createTemporaryObject(externalLinkComponent, this) + verify(item !== null) + compare(item.state, "FILLED") + + mouseMove(item, item.width / 2, item.height / 2) + verify(item.state === "FILLED" || item.state === "HOVER") + if (item.state === "HOVER") { + compare(item.textColor, Theme.color.orangeLight1) + compare(item.iconColor, Theme.color.orangeLight1) + } + + mouseMove(item, -10, -10) + tryCompare(item, "state", "FILLED") + } + + function test_external_link_disabled_ignores_hover() { + const item = createTemporaryObject(externalLinkComponent, this) + verify(item !== null) + item.parentState = "DISABLED" + wait(0) + + mouseMove(item, item.width / 2, item.height / 2) + wait(0) + compare(item.state, "DISABLED") + compare(item.textColor, Theme.color.neutral4) + } + function test_about_options_rows_are_contiguous() { const item = createTemporaryObject(aboutOptionsComponent, this) verify(item !== null) diff --git a/test/test_debuglogmodel.cpp b/test/test_debuglogmodel.cpp index cf8f133d42..0580f4ada2 100644 --- a/test/test_debuglogmodel.cpp +++ b/test/test_debuglogmodel.cpp @@ -69,6 +69,11 @@ private Q_SLOTS: void filter_updatesIncrementallyAndWhileInactive(); void rotation_fallsBackToFullSnapshot(); void loadLimit_changesKeepRetainedCacheBounded(); + void openErrorEmptyByDefault(); + void openErrorPersistsAcrossSuccessfulRead(); + void clearOpenErrorClearsOnlyWhenSet(); + void openMissingFileIsLeftToTheReader(); + void logAvailableTracksReadFailures(); }; void DebugLogModelTests::inactiveModel_ignoresRefreshUntilActivated() @@ -607,6 +612,123 @@ void DebugLogModelTests::loadLimit_changesKeepRetainedCacheBounded() QCOMPARE(insert_spy.at(0).at(2).toInt(), 3); } +void DebugLogModelTests::openErrorEmptyByDefault() +{ + QTemporaryDir temp_dir; + QVERIFY(temp_dir.isValid()); + const fs::path log_path = fs::PathFromString(temp_dir.filePath("debug.log").toStdString()); + + DebugLogModel model(log_path); + QVERIFY(model.openError().isEmpty()); +} + +// The action error (this click failed) and the state error (the log itself +// cannot be read) have different lifetimes, so they cannot share a field: the +// background reader clears its own error on every successful read, which would +// wipe the click failure on the next auto-refresh tick. +void DebugLogModelTests::openErrorPersistsAcrossSuccessfulRead() +{ + QTemporaryDir temp_dir; + QVERIFY(temp_dir.isValid()); + const QString path = temp_dir.filePath("debug.log"); + const fs::path log_path = fs::PathFromString(path.toStdString()); + QVERIFY(WriteBytes(path, NumberedRecords(0, 2))); + + // The log exists and is readable; only the hand-off to another application + // fails, which is the one thing openLogFile() still reports itself. + DebugLogModel model(log_path); + model.setOpenLocalFileFnForTesting([](const QString&) { return false; }); + QVERIFY(!model.openLogFile()); + const QString action_error = model.openError(); + QVERIFY(!action_error.isEmpty()); + + // Let a background read succeed. + model.setActive(true); + QTRY_COMPARE(model.rowCount(), 2); + + // The read succeeded, but the click failure must still be reported. + QCOMPARE(model.openError(), action_error); +} + +// clearOpenError() drops the action error (the page calls it when re-entered so +// a stale click failure does not survive navigation) and is a no-op when +// nothing is pending. +void DebugLogModelTests::clearOpenErrorClearsOnlyWhenSet() +{ + QTemporaryDir temp_dir; + QVERIFY(temp_dir.isValid()); + const QString path = temp_dir.filePath("debug.log"); + QVERIFY(WriteBytes(path, NumberedRecords(0, 1))); + + DebugLogModel model(fs::PathFromString(path.toStdString())); + model.setOpenLocalFileFnForTesting([](const QString&) { return false; }); + QSignalSpy error_spy(&model, &DebugLogModel::openErrorChanged); + + // No pending error: no change, no signal. + model.clearOpenError(); + QCOMPARE(error_spy.count(), 0); + + QVERIFY(!model.openLogFile()); + QVERIFY(!model.openError().isEmpty()); + QCOMPARE(error_spy.count(), 1); + + model.clearOpenError(); + QVERIFY(model.openError().isEmpty()); + QCOMPARE(error_spy.count(), 2); +} + +// A missing log is a state of the log, not the outcome of one click, so +// openLogFile() must not record it as an action error: doing so would let the +// page clear it on re-entry while the file is still missing. The reader owns +// that message and keeps it for as long as the condition holds. +void DebugLogModelTests::openMissingFileIsLeftToTheReader() +{ + QTemporaryDir temp_dir; + QVERIFY(temp_dir.isValid()); + const fs::path missing = fs::PathFromString(temp_dir.filePath("does-not-exist.log").toStdString()); + + DebugLogModel model(missing); + QSignalSpy error_spy(&model, &DebugLogModel::openErrorChanged); + + QVERIFY(!model.openLogFile()); + QVERIFY(model.openError().isEmpty()); + QCOMPARE(error_spy.count(), 0); + + // The reader reports it instead, and clearing on page entry does not drop + // it, because the log is still missing. + model.setActive(true); + QTRY_VERIFY(!model.openError().isEmpty()); + model.clearOpenError(); + QVERIFY(!model.openError().isEmpty()); +} + +// logAvailable drives the controls that only make sense against readable log +// content, so it has to follow the reader in both directions. +void DebugLogModelTests::logAvailableTracksReadFailures() +{ + QTemporaryDir temp_dir; + QVERIFY(temp_dir.isValid()); + const QString path = temp_dir.filePath("debug.log"); + const fs::path log_path = fs::PathFromString(path.toStdString()); + + DebugLogModel model(log_path); + QSignalSpy available_spy(&model, &DebugLogModel::logAvailableChanged); + + // Starts optimistic so the page does not flash a disabled state. + QVERIFY(model.logAvailable()); + + model.setActive(true); + QTRY_VERIFY(!model.logAvailable()); + QCOMPARE(available_spy.count(), 1); + + // The log appears and a refresh picks it up. + QVERIFY(WriteBytes(path, NumberedRecords(0, 2))); + model.refresh(true); + QTRY_VERIFY(model.logAvailable()); + QCOMPARE(available_spy.count(), 2); + QVERIFY(model.openError().isEmpty()); +} + #ifdef BITCOINQML_NO_TEST_MAIN #include BITCOINQML_REGISTER_QT_TEST(DebugLogModelTests)