WebEngine Quick Nano 浏览器

使用 WebEngineView QML 类型实现的网页浏览器。

《Quick Nano 浏览器》演示了如何使用 Qt WebEngine QML 类型 来开发一个小型网页浏览器应用程序,该应用程序包含一个具有标题栏、工具栏、标签视图和状态栏的浏览器窗口。网页内容在标签视图内的网页引擎视图中加载。如果发生证书错误,将提示用户在消息对话框中进行操作。状态栏弹出以显示悬停链接的 URL。

网页可以下发一个请求以在全屏模式下显示。用户可以通过使用工具栏按钮允许全屏模式。他们可以通过使用键盘快捷键离开全屏模式。额外的工具栏按钮可启用在浏览器历史记录中前后移动、重新加载标签内容以及打开设置菜单以启用以下功能:JavaScript、插件、全屏模式、无记录、HTTP 磁盘缓存、自动加载图像和忽略证书错误。

运行示例

要从 Qt Creator 运行示例,请打开 欢迎 模式并从 示例 中选择示例。有关更多信息,请访问 构建和运行示例

创建主浏览器窗口

当浏览器主窗口加载时,它使用默认配置创建一个空标签。每个标签都是一个填充主窗口的网页引擎视图。

我们在 BrowserWindow.qml 文件中使用 ApplicationWindow 类型创建主窗口

ApplicationWindow {
    id: browserWindow
    property QtObject applicationRoot
    property Item currentWebView: tabBar.currentIndex < tabBar.count ? tabLayout.children[tabBar.currentIndex] : null
    ...
    width: 1300
    height: 900
    visible: true
    title: currentWebView && currentWebView.title

我们使用 TabBar Qt Quick 控件在窗口顶部创建标签栏,并创建一个新的空标签

    TabBar {
        id: tabBar
        anchors.top: parent.top
        anchors.left: parent.left
        anchors.right: parent.right
        Component.onCompleted: createTab(defaultProfile)

        function createTab(profile, focusOnNewTab = true, url = undefined) {
            var webview = tabComponent.createObject(tabLayout, {profile: profile});
            var newTabButton = tabButtonComponent.createObject(tabBar, {tabTitle: Qt.binding(function () { return webview.title; })});
            tabBar.addItem(newTabButton);
            if (focusOnNewTab) {
                tabBar.setCurrentIndex(tabBar.count - 1);
            }

标签包含一个网页引擎视图,该视图加载网页内容

        Component {
            id: tabComponent
            WebEngineView {
                id: webEngineView
                focus: true

                onLinkHovered: function(hoveredUrl) {
                    if (hoveredUrl == "")
                        hideStatusText.start();
                    else {
                        statusText.text = hoveredUrl;
                        statusBubble.visible = true;
                        hideStatusText.stop();
                    }
                }

                states: [
                    State {
                        name: "FullScreen"
                        PropertyChanges {
                            target: tabBar
                            visible: false
                            height: 0
                        }
                        PropertyChanges {
                            target: navigationBar
                            visible: false
                        }
                    }
                ]
                settings.localContentCanAccessRemoteUrls: true
                settings.localContentCanAccessFileUrls: false
                settings.autoLoadImages: appSettings.autoLoadImages
                settings.javascriptEnabled: appSettings.javaScriptEnabled
                settings.errorPageEnabled: appSettings.errorPageEnabled
                settings.pluginsEnabled: appSettings.pluginsEnabled
                settings.fullScreenSupportEnabled: appSettings.fullScreenSupportEnabled
                settings.autoLoadIconsForPage: appSettings.autoLoadIconsForPage
                settings.touchIconsEnabled: appSettings.touchIconsEnabled
                settings.webRTCPublicInterfacesOnly: appSettings.webRTCPublicInterfacesOnly
                settings.pdfViewerEnabled: appSettings.pdfViewerEnabled

                onCertificateError: function(error) {
                    error.defer();
                    sslDialog.enqueue(error);
                }

                onNewWindowRequested: function(request) {
                    if (!request.userInitiated)
                        console.warn("Blocked a popup window.");
                    else if (request.destination === WebEngineNewWindowRequest.InNewTab) {
                        var tab = tabBar.createTab(currentWebView.profile, true, request.requestedUrl);
                        tab.acceptAsNewWindow(request);
                    } else if (request.destination === WebEngineNewWindowRequest.InNewBackgroundTab) {
                        var backgroundTab = tabBar.createTab(currentWebView.profile, false);
                        backgroundTab.acceptAsNewWindow(request);
                    } else if (request.destination === WebEngineNewWindowRequest.InNewDialog) {
                        var dialog = applicationRoot.createDialog(currentWebView.profile);
                        dialog.currentWebView.acceptAsNewWindow(request);
                    } else {
                        var window = applicationRoot.createWindow(currentWebView.profile);
                        window.currentWebView.acceptAsNewWindow(request);
                    }
                }

                onFullScreenRequested: function(request) {
                    if (request.toggleOn) {
                        webEngineView.state = "FullScreen";
                        browserWindow.previousVisibility = browserWindow.visibility;
                        browserWindow.showFullScreen();
                        fullScreenNotification.show();
                    } else {
                        webEngineView.state = "";
                        browserWindow.visibility = browserWindow.previousVisibility;
                        fullScreenNotification.hide();
                    }
                    request.accept();
                }

                onRegisterProtocolHandlerRequested: function(request) {
                    console.log("accepting registerProtocolHandler request for "
                                + request.scheme + " from " + request.origin);
                    request.accept();
                }

                onRenderProcessTerminated: function(terminationStatus, exitCode) {
                    var status = "";
                    switch (terminationStatus) {
                    case WebEngineView.NormalTerminationStatus:
                        status = "(normal exit)";
                        break;
                    case WebEngineView.AbnormalTerminationStatus:
                        status = "(abnormal exit)";
                        break;
                    case WebEngineView.CrashedTerminationStatus:
                        status = "(crashed)";
                        break;
                    case WebEngineView.KilledTerminationStatus:
                        status = "(killed)";
                        break;
                    }

                    print("Render process exited with code " + exitCode + " " + status);
                    reloadTimer.running = true;
                }

                onSelectClientCertificate: function(selection) {
                    selection.certificates[0].select();
                }

                onFindTextFinished: function(result) {
                    if (!findBar.visible)
                        findBar.visible = true;

                    findBar.numberOfMatches = result.numberOfMatches;
                    findBar.activeMatch = result.activeMatch;
                }

                onLoadingChanged: function(loadRequest) {
                    if (loadRequest.status == WebEngineView.LoadStartedStatus)
                        findBar.reset();
                }

                onFeaturePermissionRequested: function(securityOrigin, feature) {
                    featurePermissionDialog.securityOrigin = securityOrigin;
                    featurePermissionDialog.feature = feature;
                    featurePermissionDialog.visible = true;
                }
                onWebAuthUxRequested: function(request) {
                    webAuthDialog.init(request);
                }

                Timer {
                    id: reloadTimer
                    interval: 0
                    running: false
                    repeat: false
                    onTriggered: currentWebView.reload()
                }
            }
        }

我们使用 Action 类型创建新的标签

    Action {
        shortcut: StandardKey.AddTab
        onTriggered: {
            tabBar.createTab(tabBar.count != 0 ? currentWebView.profile : defaultProfile);
            addressBar.forceActiveFocus();
            addressBar.selectAll();
        }

我们使用位于 ToolBar 中的 TextField Qt Quick 控件创建地址栏,该地址栏显示当前 URL 并允许用户输入另一个 URL

    menuBar: ToolBar {
        id: navigationBar
        RowLayout {
            anchors.fill: parent
    ...
            TextField {
                id: addressBar
    ...
                focus: true
                Layout.fillWidth: true
                Binding on text {
                    when: currentWebView
                    value: currentWebView.url
                }
                onAccepted: currentWebView.url = Utils.fromUserInput(text)
                selectByMouse: true
            }

处理证书错误

如果正在加载的网站的证书触发证书错误,我们将调用 QML 方法 defer() 来暂停 URL 请求并等待用户输入

                onCertificateError: function(error) {
                    error.defer();
                    sslDialog.enqueue(error);
                }

我们使用对话框类型来提示用户是否继续或取消加载网页。如果用户选择 ,我们将调用 acceptCertificate() 方法来继续从URL加载内容。如果用户选择 ,我们将调用 rejectCertificate() 方法拒绝请求并停止从URL加载内容

    Dialog {
        id: sslDialog
        anchors.centerIn: parent
        contentWidth: Math.max(mainTextForSSLDialog.width, detailedTextForSSLDialog.width)
        contentHeight: mainTextForSSLDialog.height + detailedTextForSSLDialog.height
        property var certErrors: []
        // fixme: icon!
        // icon: StandardIcon.Warning
        standardButtons: Dialog.No | Dialog.Yes
        title: "Server's certificate not trusted"
        contentItem: Item {
            Label {
                id: mainTextForSSLDialog
                text: "Do you wish to continue?"
            }
            Text {
                id: detailedTextForSSLDialog
                anchors.top: mainTextForSSLDialog.bottom
                text: "If you wish so, you may continue with an unverified certificate.\n" +
                      "Accepting an unverified certificate means\n" +
                      "you may not be connected with the host you tried to connect to.\n" +
                      "Do you wish to override the security check and continue?"
            }
        }

        onAccepted: {
            certErrors.shift().acceptCertificate();
            presentError();
        }
        onRejected: reject()

        function reject(){
            certErrors.shift().rejectCertificate();
            presentError();
        }
        function enqueue(error){
            certErrors.push(error);
            presentError();
        }
        function presentError(){
            visible = certErrors.length > 0
        }
    }

处理功能权限请求

我们使用 onFeaturePermissionRequested() 信号处理器来处理访问特定功能或设备的请求。参数 securityOrigin 识别请求的网站,参数 feature 是请求的功能。我们使用这些信息来构造对话框消息

                onFeaturePermissionRequested: function(securityOrigin, feature) {
                    featurePermissionDialog.securityOrigin = securityOrigin;
                    featurePermissionDialog.feature = feature;
                    featurePermissionDialog.visible = true;
                }

我们显示一个对话框,询问用户是允许还是拒绝访问。自定义 questionForFeature() JavaScript 函数生成关于请求的人可读问题。如果用户选择 ,我们使用带有第三个参数 truegrantFeaturePermission() 方法,授予 securityOrigin 网站访问 feature 的权限。如果用户选择 ,我们使用带有参数 false 的同一方法拒绝访问

    Dialog {
        id: featurePermissionDialog
        anchors.centerIn: parent
        width: Math.min(browserWindow.width, browserWindow.height) / 3 * 2
        contentWidth: mainTextForPermissionDialog.width
        contentHeight: mainTextForPermissionDialog.height
        standardButtons: Dialog.No | Dialog.Yes
        title: "Permission Request"

        property var feature;
        property url securityOrigin;

        contentItem: Item {
            Label {
                id: mainTextForPermissionDialog
                text: featurePermissionDialog.questionForFeature()
            }
        }

        onAccepted: currentWebView && currentWebView.grantFeaturePermission(securityOrigin, feature, true)
        onRejected: currentWebView && currentWebView.grantFeaturePermission(securityOrigin, feature, false)
        onVisibleChanged: {
            if (visible)
                width = contentWidth + 20;
        }

        function questionForFeature() {
            var question = "Allow " + securityOrigin + " to "

            switch (feature) {
            case WebEngineView.Geolocation:
                question += "access your location information?";
                break;
            case WebEngineView.MediaAudioCapture:
                question += "access your microphone?";
                break;
            case WebEngineView.MediaVideoCapture:
                question += "access your webcam?";
                break;
            case WebEngineView.MediaVideoCapture:
                question += "access your microphone and webcam?";
                break;
            case WebEngineView.MouseLock:
                question += "lock your mouse cursor?";
                break;
            case WebEngineView.DesktopVideoCapture:
                question += "capture video of your desktop?";
                break;
            case WebEngineView.DesktopAudioVideoCapture:
                question += "capture audio and video of your desktop?";
                break;
            case WebEngineView.Notifications:
                question += "show notification on your desktop?";
                break;
            default:
                question += "access unknown or unsupported feature [" + feature + "] ?";
                break;
            }

            return question;
        }
    }

进入和退出全屏模式

我们在工具栏上的设置菜单中创建一个允许全屏模式的菜单项。此外,我们通过使用键盘快捷键创建一个退出全屏模式的行为。我们调用 accept() 方法来接受全屏请求。此方法将 isFullScreen 属性设置与 toggleOn 属性相等。

                onFullScreenRequested: function(request) {
                    if (request.toggleOn) {
                        webEngineView.state = "FullScreen";
                        browserWindow.previousVisibility = browserWindow.visibility;
                        browserWindow.showFullScreen();
                        fullScreenNotification.show();
                    } else {
                        webEngineView.state = "";
                        browserWindow.visibility = browserWindow.previousVisibility;
                        fullScreenNotification.hide();
                    }
                    request.accept();
                }

进入全屏模式时,我们使用在 FullScreenNotification.qml 中创建的 FullScreenNotification 自定义类型来显示通知。

我们在设置菜单中使用了 Action 类型,通过按下Esc键来创建退出全屏模式的快捷键。

    Settings {
        id : appSettings
        property alias fullScreenSupportEnabled: fullScreenSupportEnabled.checked
        property alias autoLoadIconsForPage: autoLoadIconsForPage.checked
        property alias touchIconsEnabled: touchIconsEnabled.checked
        property alias webRTCPublicInterfacesOnly : webRTCPublicInterfacesOnly.checked
        property alias devToolsEnabled: devToolsEnabled.checked
        property alias pdfViewerEnabled: pdfViewerEnabled.checked
    }

    Action {
        shortcut: "Escape"
        onTriggered: {
            if (currentWebView.state == "FullScreen") {
                browserWindow.visibility = browserWindow.previousVisibility;
                fullScreenNotification.hide();
                currentWebView.triggerWebAction(WebEngineView.ExitFullScreen);
            }

            if (findBar.visible)
                findBar.visible = false;
        }
    }

处理 WebAuth/FIDO UX 请求

我们使用 onWebAuthUxRequested() 信号处理器来处理 WebAuth/FIDO UX 的请求。参数 request 是一个包含 UX 请求详情和应用程序处理此请求所需的 API 的 WebEngineWebAuthUxRequest 实例。我们使用它来构建 WebAuthUX 对话框并启动 UX 请求流程。

                onWebAuthUxRequested: function(request) {
                    webAuthDialog.init(request);
                }

WebEngineWebAuthUxRequest 对象定期发出 stateChanged 信号,以通知潜在的观察者当前的 WebAuth UX 状态。观察者相应地更新 WebAuth 对话框。我们使用 onStateChanged() 信号处理器来处理状态变化请求。请参阅 WebAuthDialog.qml 中的示例,了解如何处理这些信号。

    Connections {
        id: webauthConnection
        ignoreUnknownSignals: true
        function onStateChanged(state) {
            webAuthDialog.setupUI(state);
        }
    function init(request) {
        pinLabel.visible = false;
        pinEdit.visible = false;
        confirmPinLabel.visible = false;
        confirmPinEdit.visible = false;
        selectAccountModel.clear();
        webAuthDialog.authrequest = request;
        webauthConnection.target = request;
        setupUI(webAuthDialog.authrequest.state)
        webAuthDialog.visible = true;
        pinEntryError.visible = false;
    }

macOS 的签名要求

为了在 macOS 上运行 Quick Nano Browser 时允许网站访问位置、摄像头和麦克风,应用程序需要经过签名。在构建过程中自动完成,但您需要为构建环境设置有效的签名身份。

文件和归功于

本例使用了来自Tango图标库的图标

Tango图标库公有领域

示例项目 @ code.qt.io

© 2024 Qt公司有限公司。本文件中包含的文档贡献均为各自所有者的版权。本文件中提供的文档根据由自由软件基金会发布的GNU自由文档许可证1.3版条款授权。Qt及其相关商标为Qt公司有限公司在芬兰及全球其他国家的商标。所有其他商标均为其各自所有者的财产。