[neon/neon-packaging/plasma-bigscreen/Neon/unstable] debian/patches: webviewer.diff

Carlos De Maine null at kde.org
Tue Jun 24 04:47:29 BST 2025


Git commit 98ab4c9c5c7b30f0227ef3ff59a11461f413edb4 by Carlos De Maine.
Committed on 24/06/2025 at 03:47.
Pushed by carlosdem into branch 'Neon/unstable'.

webviewer.diff

M  +1    -1    debian/patches/series
D  +0    -365  debian/patches/work_devnlin_fixmediacenter.diff
A  +2052 -0    debian/patches/work_devnlin_webviewer.diff

https://invent.kde.org/neon/neon-packaging/plasma-bigscreen/-/commit/98ab4c9c5c7b30f0227ef3ff59a11461f413edb4

diff --git a/debian/patches/series b/debian/patches/series
index 0fede92..ba6302f 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -1 +1 @@
-work_devinlin_fixmediacenter.diff
+work_devinlin_webviewer.diff
diff --git a/debian/patches/work_devnlin_fixmediacenter.diff b/debian/patches/work_devnlin_fixmediacenter.diff
deleted file mode 100644
index c7c78ad..0000000
--- a/debian/patches/work_devnlin_fixmediacenter.diff
+++ /dev/null
@@ -1,365 +0,0 @@
-diff --git a/kcm/ui/+mediacenter/DeviceMap.qml b/kcm/ui/+mediacenter/DeviceMap.qml
-index 992c0816f95cf38424fe1bcf30fd2a9f9a3703bc..b8bbcd6a2af61a4c02f9838414b0cf7ee8a99490 100644
---- a/kcm/ui/+mediacenter/DeviceMap.qml
-+++ b/kcm/ui/+mediacenter/DeviceMap.qml
-@@ -1,16 +1,17 @@
- /*
-     SPDX-FileCopyrightText: 2020 Aditya Mehra <aix.m at outlook.com>
--    
-+
-     SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
- */
- 
- import QtQuick 2.14
- import QtQuick.Layouts 1.14
- import QtQuick.Controls 2.14
--import org.kde.plasma.components 2.0 as PlasmaComponents
-+
- import org.kde.kirigami 2.12 as Kirigami
- import org.kde.kitemmodels 1.0 as KItemModels
- import org.kde.private.kcm.remotecontrollers 1.0
-+
- import "./delegates" as Delegates
- 
- Item {
-@@ -53,9 +54,9 @@ Item {
-             ListView {
-                 id: buttonMapRepeater
-                 anchors.fill: parent
--                model: KItemModels.KSortFilterProxyModel { 
-+                model: KItemModels.KSortFilterProxyModel {
-                     sourceModel: kcm.keyMapModel
--                    filterRole: "DeviceTypeRole"
-+                    filterRoleName: "DeviceTypeRole"
-                     filterRowCallback: function(source_row, source_parent) {
-                         var filter_device = deviceMap.deviceType == 0 ? "CEC" : "GAMEPAD"
-                         return sourceModel.data(sourceModel.index(source_row, 0, source_parent), KeyMapModel.DeviceTypeRole).indexOf(filter_device) !== -1;
-diff --git a/kcm/ui/+mediacenter/DeviceSetupView.qml b/kcm/ui/+mediacenter/DeviceSetupView.qml
-index 220ade7448236dd2015ab873527f9b826285df29..2fb5991d1203068592e4c25790aa893c588ac259 100644
---- a/kcm/ui/+mediacenter/DeviceSetupView.qml
-+++ b/kcm/ui/+mediacenter/DeviceSetupView.qml
-@@ -9,12 +9,14 @@ import QtQuick 2.14
- import QtQuick.Layouts 1.14
- import QtQuick.Controls 2.14
- import org.kde.plasma.core 2.0 as PlasmaCore
--import org.kde.kirigami 2.20 as Kirigami
--import org.kde.plasma.components 2.0 as PlasmaComponents2
--import org.kde.plasma.components 3.0 as PlasmaComponents
--import org.kde.mycroft.bigscreen 1.0 as BigScreen
- import Qt5Compat.GraphicalEffects
- 
-+import org.kde.kirigami 2.20 as Kirigami
-+import org.kde.plasma.components as PlasmaComponents
-+import org.kde.plasma.extras as PlasmaExtras
-+import org.kde.bigscreen as BigScreen
-+
-+
- Rectangle {
-     id: deviceView
-     color: Kirigami.Theme.backgroundColor
-@@ -35,13 +37,13 @@ Rectangle {
-             deviceView.ignoreEvent = false
-         }
-     }
--    
-+
-     onActiveFocusChanged: {
-         if(activeFocus){
-             deviceMapView.forceActiveFocus()
-         }
-     }
--    
-+
-     ColumnLayout {
-         id: colLayoutSettingsItem
-         clip: true
-@@ -58,7 +60,7 @@ Rectangle {
-             Layout.fillWidth: true
-             Layout.preferredHeight: parent.height * 0.10
-         }
--        
-+
-         Item {
-             Layout.fillWidth: true
-             Layout.preferredHeight: dIcon.height + (label2.height + Kirigami.Units.largeSpacing * 2)
-@@ -66,23 +68,23 @@ Rectangle {
- 
-             Rectangle {
-                 id: dIcon
--                anchors.top: headrSept.bottom
-+                anchors.top: parent.top
-                 anchors.topMargin: Kirigami.Units.largeSpacing
-                 anchors.horizontalCenter: parent.horizontalCenter
-                 width: Kirigami.Units.iconSizes.huge
-                 height: width
-                 radius: 100
-                 color: Kirigami.Theme.backgroundColor
--                
-+
-                 Kirigami.Icon {
-                     id: deviceIconStatus
-                     anchors.centerIn: parent
-                     width: Kirigami.Units.iconSizes.large
-                     height: width
--                    source: currentDevice.deviceIconName
-+                    source: currentDevice ? currentDevice.deviceIconName : ''
-                 }
-             }
--            
-+
-             Kirigami.Heading {
-                 id: label2
-                 width: parent.width
-@@ -94,7 +96,7 @@ Rectangle {
-                 maximumLineCount: 2
-                 elide: Text.ElideRight
-                 color: PlasmaCore.ColorScope.textColor
--                text: i18n(currentDevice.deviceName)
-+                text: currentDevice ? currentDevice.deviceName : ''
-             }
- 
-             Kirigami.Separator {
-@@ -123,15 +125,15 @@ Rectangle {
-         anchors.margins: Kirigami.Units.largeSpacing * 2
-         height: Kirigami.Units.gridUnit * 2
- 
--        PlasmaComponents2.Button {
-+        PlasmaComponents.Button {
-             id: backBtnSettingsItem
--            iconSource: "arrow-left"
-+            icon.name: "arrow-left"
-             Layout.alignment: Qt.AlignLeft
- 
-             KeyNavigation.up: deviceMapView
-             KeyNavigation.down: deviceMapView
- 
--            PlasmaComponents2.Highlight {
-+            PlasmaExtras.Highlight {
-                 z: -2
-                 anchors.fill: parent
-                 anchors.margins: -Kirigami.Units.gridUnit / 4
-diff --git a/kcm/ui/+mediacenter/delegates/DeviceDelegate.qml b/kcm/ui/+mediacenter/delegates/DeviceDelegate.qml
-index bafa9f20b2809d457f572ec002b57d52e694eab0..df8267ba08128b98b36085d8db1d67a2d6428930 100644
---- a/kcm/ui/+mediacenter/delegates/DeviceDelegate.qml
-+++ b/kcm/ui/+mediacenter/delegates/DeviceDelegate.qml
-@@ -8,12 +8,12 @@
- import QtQuick 2.14
- import QtQuick.Layouts 1.14
- import QtQuick.Controls 2.14
-+import Qt5Compat.GraphicalEffects
-+
- import org.kde.plasma.core 2.0 as PlasmaCore
--import org.kde.kirigami 2.20 as Kirigami
--import org.kde.plasma.components 2.0 as PlasmaComponents2
--import org.kde.plasma.components 3.0 as PlasmaComponents
--import org.kde.mycroft.bigscreen 1.0 as BigScreen
--import QtGraphicalEffects 1.14
-+import org.kde.kirigami as Kirigami
-+import org.kde.plasma.components as PlasmaComponents
-+import org.kde.bigscreen as BigScreen
- 
- BigScreen.AbstractDelegate {
-     id: delegate
-@@ -22,18 +22,18 @@ BigScreen.AbstractDelegate {
-     implicitHeight: listView.height
-     property QtObject device: model
-     property var deviceType: model.deviceType
--    
-+
-     Behavior on implicitWidth {
-         NumberAnimation {
-             duration: Kirigami.Units.longDuration
-             easing.type: Easing.InOutQuad
-         }
-     }
--    
-+
-     Keys.onReturnPressed: {
-         clicked();
-     }
--    
-+
-     onClicked: {
-         listView.currentIndex = index
-         deviceSetupView.forceActiveFocus()
-@@ -41,13 +41,13 @@ BigScreen.AbstractDelegate {
- 
-     contentItem: Item {
-         id: deviceItemLayout
--        
-+
-         Item {
-             id: deviceSvgIcon
-             width: Kirigami.Units.iconSizes.huge
-             height: width
-             y: deviceItemLayout.height / 2 - deviceSvgIcon.height / 2
--            
-+
-             Kirigami.Icon {
-                 anchors.centerIn: parent
-                 source: model.deviceIconName
-@@ -55,10 +55,10 @@ BigScreen.AbstractDelegate {
-                 height: width
-             }
-         }
--        
-+
-         ColumnLayout {
-             id: textLayout
--            
-+
-             anchors {
-                 left: deviceSvgIcon.right
-                 right: deviceItemLayout.right
-diff --git a/kcm/ui/+mediacenter/main.qml b/kcm/ui/+mediacenter/main.qml
-index 6b2fd03765904afa68114b86f81c922b701460a8..f569e5a89197945fe083cab965daaf9987f3d3eb 100644
---- a/kcm/ui/+mediacenter/main.qml
-+++ b/kcm/ui/+mediacenter/main.qml
-@@ -5,15 +5,17 @@
- 
- */
- 
--import QtQuick.Layouts 1.14
--import QtQuick 2.14
--import QtQuick.Window 2.14
--import QtQuick.Controls 2.14
--import org.kde.kirigami 2.20 as Kirigami
--import org.kde.plasma.components 2.0 as PlasmaComponents
-+import QtQuick
-+import QtQuick.Layouts
-+import QtQuick.Window
-+import QtQuick.Controls
-+
-+import org.kde.kirigami as Kirigami
-+import org.kde.plasma.components as PlasmaComponents
- import org.kde.kcmutils as KCM
--import org.kde.mycroft.bigscreen 1.0 as BigScreen
-+import org.kde.bigscreen as BigScreen
- import org.kde.private.kcm.remotecontrollers 1.0
-+
- import "+mediacenter/delegates" as Delegates
- 
- KCM.SimpleKCM {
-@@ -21,20 +23,24 @@ KCM.SimpleKCM {
- 
-     title: i18n("Remote Controllers")
-     background: null
-+
-     leftPadding: Kirigami.Units.smallSpacing
--    topPadding: 0
-+    topPadding: Kirigami.Units.smallSpacing
-     rightPadding: Kirigami.Units.smallSpacing
--    bottomPadding: 0
-+    bottomPadding: Kirigami.Units.smallSpacing
-+
-     property var supportedControllers: kcm.devicesModel
- 
--    Component.onCompleted: {
--        connectionView.forceActiveFocus();
-+    onActiveFocusChanged: {
-+        if (activeFocus) {
-+            connectionView.forceActiveFocus();
-+        }
-     }
- 
-     Connections {
-         target: kcm.devicesModel
-         onDevicesChanged: {
--            if(connectionView.count > 0) {
-+            if (connectionView.count > 0) {
-                 deviceSetupView.currentDevice = connectionView.currentItem
-                 deviceSetupView.deviceType = connectionView.currentItem.deviceType
-             }
-@@ -67,54 +73,11 @@ KCM.SimpleKCM {
-             }
-         }
- 
--        Item {
--            id: footerMain
--            anchors.left: parent.left
--            anchors.right: deviceSetupView.left
--            anchors.leftMargin: -Kirigami.Units.largeSpacing
--            anchors.bottom: parent.bottom
--            implicitHeight: Kirigami.Units.gridUnit * 2
--
--            Button {
--                id: kcmcloseButton
--                implicitHeight: Kirigami.Units.gridUnit * 2
--                width: supportedControllers.count > 0 ? parent.width : (root.width + Kirigami.Units.largeSpacing)
--
--                background: Rectangle {
--                    color: kcmcloseButton.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.backgroundColor
--                }
--
--                contentItem: Item {
--                    RowLayout {
--                        anchors.centerIn: parent
--                        Kirigami.Icon {
--                            Layout.preferredWidth: Kirigami.Units.iconSizes.small
--                            Layout.preferredHeight: Kirigami.Units.iconSizes.small
--                            source: "window-close"
--                        }
--                        Label {
--                            text: i18n("Exit")
--                        }
--                    }
--                }
--
--                Keys.onUpPressed: connectionView.forceActiveFocus()
--
--                onClicked: {
--                    Window.window.close()
--                }
--
--                Keys.onReturnPressed: {
--                    Window.window.close()
--                }
--            }
--        }
--
-         Item {
-             clip: true
-             anchors.left: parent.left
-             anchors.top: headerAreaTop.bottom
--            anchors.bottom: footerMain.top
-+            anchors.bottom: parent.bottom
-             width: parent.width - deviceSetupView.width
- 
-             ColumnLayout {
-@@ -125,20 +88,19 @@ KCM.SimpleKCM {
-                 BigScreen.TileView {
-                     id: connectionView
-                     focus: true
--                    model:  supportedControllers
-+                    model: supportedControllers
-                     Layout.alignment: Qt.AlignTop
-                     cellWidth: (Kirigami.Units.iconSizes.huge + Kirigami.Units.largeSpacing*6)
-                     title: supportedControllers.count > 0 ? i18n("Found Devices") : i18n("No Devices Found")
-                     currentIndex: 0
--                    delegate: Delegates.DeviceDelegate{}
--                    navigationDown: kcmcloseButton
-+                    delegate: Delegates.DeviceDelegate {}
-                     Behavior on x {
-                         NumberAnimation {
-                             duration: Kirigami.Units.longDuration * 2
-                             easing.type: Easing.InOutQuad
-                         }
-                     }
--                    
-+
-                     onCurrentItemChanged: {
-                         deviceSetupView.currentDevice = currentItem.device
-                         deviceSetupView.deviceType = currentItem.deviceType
-@@ -196,9 +158,9 @@ KCM.SimpleKCM {
-             Connections {
-                 target: kcm
-                 onGamepadKeyPressed: {
--                    if(keySetupGamepadPopUp.opened) {
--                        if(kcm.gamepadKeyConfig("ButtonEnter") == keyCode) {
--                            deviceSetupView.ignoreEvent = true       
-+                    if (keySetupGamepadPopUp.opened) {
-+                        if (kcm.gamepadKeyConfig("ButtonEnter") == keyCode) {
-+                            deviceSetupView.ignoreEvent = true
-                         }
-                         kcm.setGamepadKeyConfig(keySetupGamepadPopUp.keyType[1], keyCode)
-                         keySetupGamepadPopUp.close()
diff --git a/debian/patches/work_devnlin_webviewer.diff b/debian/patches/work_devnlin_webviewer.diff
new file mode 100644
index 0000000..4e75ea7
--- /dev/null
+++ b/debian/patches/work_devnlin_webviewer.diff
@@ -0,0 +1,2052 @@
+diff --git a/CMakeLists.txt b/CMakeLists.txt
+index 74c5e801a93b6113d321cbf14e894f9ca02de6a3..5ad27c1f3914313c085e5bdb08e3f1a95f8b85ec 100644
+--- a/CMakeLists.txt
++++ b/CMakeLists.txt
+@@ -10,6 +10,7 @@ set(PROJECT_VERSION "6.4.80")
+ 
+ set(QT_MIN_VERSION "6.8.0")
+ set(KF_MIN_VERSION "6.14.0")
++set(QCORO_MIN_VERSION "0.7.0")
+ 
+ set(CMAKE_CXX_STANDARD 20)
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
+@@ -61,7 +62,14 @@ find_package(Qt6 ${QT_MIN_VERSION} CONFIG REQUIRED COMPONENTS
+     DBus
+     Network
+     Multimedia
++    WebEngineCore
++    WebEngineQuick
+ )
++find_package(QCoro6 ${QCORO_MIN_VERSION} REQUIRED COMPONENTS Core Quick Qml)
++
++add_definitions(-DQT_NO_FOREACH -DQT_NO_URL_CAST_FROM_STRING)
++kde_enable_exceptions()
++qcoro_enable_coroutines()
+ 
+ ecm_find_qmlmodule(org.kde.plasma.core 2.0)
+ 
+@@ -76,6 +84,7 @@ add_subdirectory(components)
+ add_subdirectory(sounds)
+ add_subdirectory(envmanager)
+ add_subdirectory(uvcviewer)
++add_subdirectory(webapp-viewer)
+ 
+ plasma_install_package(shell org.kde.plasma.bigscreen shells)
+ plasma_install_package(lookandfeel org.kde.plasma.bigscreen look-and-feel lookandfeel)
+diff --git a/README.md b/README.md
+index 7d7db87d911d6d3aa5f739e9d30e100f539891c9..0bdb853b267ba2deed02f5c808d2e13c0a663f00 100644
+--- a/README.md
++++ b/README.md
+@@ -63,6 +63,10 @@ Note that `kdesrc-build` doesn't automatically build `plasma-nano` and `plasma-s
+ - DBus
+ - Network
+ 
++### Other dependencies
++
++- QCoro
++
+ </details>
+ 
+ To start the Bigscreen homescreen in a window, use the following script:
+diff --git a/components/bigscreenplugin/qml/AbstractDelegate.qml b/components/bigscreenplugin/qml/AbstractDelegate.qml
+index ee3108a5752da45b643e9e0d034c674d8a5a95a2..8b3c0fc79ea82ffdce25de3043d7e07da159c167 100644
+--- a/components/bigscreenplugin/qml/AbstractDelegate.qml
++++ b/components/bigscreenplugin/qml/AbstractDelegate.qml
+@@ -39,8 +39,10 @@ QQC2.ItemDelegate {
+     z: isCurrent ? 2 : 0
+ 
+     onClicked: {
+-        listView.forceActiveFocus()
+-        listView.currentIndex = index
++        if (listView) {
++            listView.forceActiveFocus()
++            listView.currentIndex = index
++        }
+     }
+ 
+     leftPadding: Kirigami.Units.gridUnit
+diff --git a/components/bigscreenplugin/qml/KCMAbstractDelegate.qml b/components/bigscreenplugin/qml/KCMAbstractDelegate.qml
+index d3286eaa6c000d32360f3cb6b157bc3df45611f8..efd81148753971ce2d16a568d26e9839a164d083 100644
+--- a/components/bigscreenplugin/qml/KCMAbstractDelegate.qml
++++ b/components/bigscreenplugin/qml/KCMAbstractDelegate.qml
+@@ -42,11 +42,7 @@ AbstractDelegate {
+     }
+ 
+     Keys.onLeftPressed: (event)=> {
+-        if(listView && listView.currentIndex == 0){
+-            settingMenuItemFocus()
+-        } else {
+-            event.accepted = false
+-        }
++        event.accepted = false
+     }
+ 
+     contentItem: Item {
+diff --git a/kcms/CMakeLists.txt b/kcms/CMakeLists.txt
+index b44038555c491aaeaa6b12e09a62cc960244a1c8..3adf474945f63870a0982ba3fae2df04195083c6 100644
+--- a/kcms/CMakeLists.txt
++++ b/kcms/CMakeLists.txt
+@@ -6,4 +6,5 @@
+ add_subdirectory(wifi)
+ add_subdirectory(kdeconnect)
+ add_subdirectory(bigscreen-settings)
++add_subdirectory(webapps)
+ # add_subdirectory(display)
+\ No newline at end of file
+diff --git a/kcms/webapps/CMakeLists.txt b/kcms/webapps/CMakeLists.txt
+new file mode 100644
+index 0000000000000000000000000000000000000000..75d761eff40a0b8d1568b18d337160222ad6236b
+--- /dev/null
++++ b/kcms/webapps/CMakeLists.txt
+@@ -0,0 +1,27 @@
++# SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++# SPDX-License-Identifier: GPL-2.0-or-later
++
++set(webappskcm_SRCS
++    webappskcm.cpp
++    webappcreator.cpp
++    webappmanager.cpp
++    webappmanagermodel.cpp
++)
++
++kcmutils_add_qml_kcm(kcm_mediacenter_webapps SOURCES ${webappskcm_SRCS})
++
++target_link_libraries(kcm_mediacenter_webapps
++    Qt::DBus
++    Qt::Gui
++    Qt::Quick
++    Qt::Qml
++    QCoro6::Qml
++    KF6::ConfigCore
++    KF6::Svg
++    KF6::I18n
++    KF6::KCMUtilsQuick
++    KF6::ConfigWidgets
++    KF6::CoreAddons
++    KF6::Package
++    Plasma::Plasma
++)
+diff --git a/kcms/webapps/kcm_mediacenter_webapps.json b/kcms/webapps/kcm_mediacenter_webapps.json
+new file mode 100644
+index 0000000000000000000000000000000000000000..9b354c2726f15abedada53d5284a4479fc877037
+--- /dev/null
++++ b/kcms/webapps/kcm_mediacenter_webapps.json
+@@ -0,0 +1,12 @@
++{
++    "KPlugin": {
++        "Description": "Create and manage web applications",
++        "FormFactors": [
++            "mediacenter"
++        ],
++        "Icon": "internet-web-browser-symbolic",
++        "Name": "Web Apps"
++    },
++    "X-KDE-System-Settings-Parent-Category": "appearance",
++    "X-KDE-Weight": 40
++}
+diff --git a/kcms/webapps/ui/WebAppInfoSidebar.qml b/kcms/webapps/ui/WebAppInfoSidebar.qml
+new file mode 100644
+index 0000000000000000000000000000000000000000..27f414c00a0ac6284fa691b375654fa8c2910416
+--- /dev/null
++++ b/kcms/webapps/ui/WebAppInfoSidebar.qml
+@@ -0,0 +1,100 @@
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org
++// SPDX-License-Identifier: GPL-2.0-or-later OR LicenseRef-KDE-Accepted-LGPL
++
++import QtQuick
++import QtQuick.Layouts
++import QtQuick.Controls as QQC2
++
++import org.kde.kirigami as Kirigami
++import org.kde.kcmutils as KCM
++import org.kde.bigscreen as Bigscreen
++import org.kde.bigscreen.webappskcm as WebAppsKCM
++
++Bigscreen.SidebarOverlay {
++    id: root
++    openFocusItem: nameButtonDelegate
++
++    property int modelIndex
++    property string icon
++    property string name
++    property string url
++    property string userAgent
++
++    header: ColumnLayout {
++        spacing: Kirigami.Units.gridUnit
++        Item { Layout.fillHeight: true }
++
++        Kirigami.Icon {
++            Layout.alignment: Qt.AlignHCenter
++            implicitWidth: 128
++            implicitHeight: 128
++            source: root.icon
++        }
++        QQC2.Label {
++            text: root.name
++
++            Layout.fillWidth: true
++            horizontalAlignment: Text.AlignHCenter
++            wrapMode: Text.WordWrap
++            maximumLineCount: 2
++            elide: Text.ElideRight
++            font.pixelSize: 32
++            font.weight: Font.Light
++        }
++    }
++
++    content: ColumnLayout {
++        spacing: Kirigami.Units.largeSpacing
++        Keys.onLeftPressed: root.close()
++
++        Bigscreen.ButtonDelegate {
++            id: nameButtonDelegate
++            text: i18n("Name")
++            description: root.name
++
++            KeyNavigation.down: urlButtonDelegate
++        }
++
++        Bigscreen.ButtonDelegate {
++            id: urlButtonDelegate
++            text: i18n("URL")
++            description: root.url
++
++            KeyNavigation.down: descriptionButtonDelegate
++        }
++
++        Bigscreen.ButtonDelegate {
++            id: descriptionButtonDelegate
++            text: i18n("User Agent")
++            description: root.userAgent.length > 0 ? root.userAgent : i18n("Default user agent")
++
++            KeyNavigation.down: deleteButtonDelegate
++        }
++        Item { Layout.fillHeight: true }
++
++        Bigscreen.ButtonDelegate {
++            id: deleteButtonDelegate
++            icon.name: 'delete'
++            text: i18n("Delete")
++
++            onClicked: {
++                deleteConfirmDialog.open();
++            }
++
++            Bigscreen.Dialog {
++                id: deleteConfirmDialog
++                title: i18n("Delete web app %1?", root.name)
++                standardButtons: Bigscreen.Dialog.Ok | Bigscreen.Dialog.Cancel
++
++                onAccepted: {
++                    deleteConfirmDialog.close();
++                    root.close();
++                    WebAppsKCM.WebAppManagerModel.removeApp(root.modelIndex);
++                }
++                onRejected: {
++                    deleteButtonDelegate.forceActiveFocus();
++                }
++            }
++        }
++    }
++}
+\ No newline at end of file
+diff --git a/kcms/webapps/ui/main.qml b/kcms/webapps/ui/main.qml
+new file mode 100644
+index 0000000000000000000000000000000000000000..5a2d2898b3acce2018da1f8767ab4b3669dfc0bb
+--- /dev/null
++++ b/kcms/webapps/ui/main.qml
+@@ -0,0 +1,138 @@
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org
++// SPDX-License-Identifier: GPL-2.0-or-later OR LicenseRef-KDE-Accepted-LGPL
++
++import QtQuick
++import QtQuick.Layouts
++import QtQuick.Controls as QQC2
++
++import org.kde.kirigami as Kirigami
++import org.kde.kcmutils as KCM
++import org.kde.bigscreen as Bigscreen
++import org.kde.bigscreen.webappskcm as WebAppsKCM
++
++Kirigami.ScrollablePage {
++    id: root
++
++    title: i18n("Web Apps")
++
++    background: null
++    leftPadding: Kirigami.Units.smallSpacing
++    topPadding: Kirigami.Units.smallSpacing
++    rightPadding: Kirigami.Units.smallSpacing
++    bottomPadding: Kirigami.Units.smallSpacing
++
++    onActiveFocusChanged: {
++        if (activeFocus) {
++            addWebApp.forceActiveFocus();
++        }
++    }
++
++    ColumnLayout {
++        KeyNavigation.left: root.KeyNavigation.left
++        spacing: 0
++
++        Bigscreen.ButtonDelegate {
++            id: addWebApp
++
++            onClicked: {
++                addWebAppDialog.open()
++                nameTextField.forceActiveFocus()
++            }
++
++            text: i18n('Add web app')
++            icon.name: 'list-add'
++
++            KeyNavigation.down: webAppListView
++        }
++
++        QQC2.Label {
++            text: i18n('Installed Web Apps')
++            font.pixelSize: 22
++            font.weight: Font.Normal
++            Layout.fillWidth: true
++            Layout.topMargin: Kirigami.Units.gridUnit
++            Layout.bottomMargin: Kirigami.Units.gridUnit
++        }
++
++        ListView {
++            id: webAppListView
++            Layout.fillWidth: true
++
++            implicitHeight: contentHeight
++            model: WebAppsKCM.WebAppManagerModel
++            currentIndex: 0
++            spacing: Kirigami.Units.smallSpacing
++
++            delegate: Bigscreen.ButtonDelegate {
++                id: delegate
++
++                icon.name: model.desktopIcon
++                text: model.name
++                description: model.url
++                width: webAppListView.width
++
++                onClicked: {
++                    delegateInfoDialog.delegate = delegate
++                    delegateInfoDialog.modelIndex = model.index;
++                    delegateInfoDialog.icon = model.desktopIcon;
++                    delegateInfoDialog.name = model.name;
++                    delegateInfoDialog.url = model.url;
++                    delegateInfoDialog.userAgent = model.userAgent;
++                    delegateInfoDialog.open();
++                }
++            }
++        }
++
++        Bigscreen.Dialog {
++            id: addWebAppDialog
++            title: i18n("Add web application")
++            standardButtons: Bigscreen.Dialog.Ok | Bigscreen.Dialog.Cancel
++            openFocusItem: nameTextField
++
++            onClosed: addWebApp.forceActiveFocus()
++            onAccepted: {
++                WebAppsKCM.WebAppCreator.addEntry(nameTextField.text, urlTextField.text, 'internet-web-browser', userAgentTextField.text);
++                close();
++            }
++
++            contentItem: ColumnLayout {
++                spacing: Kirigami.Units.largeSpacing
++
++                Bigscreen.TextField {
++                    id: nameTextField
++                    Layout.fillWidth: true
++                    placeholderText: i18n("Name")
++
++                    KeyNavigation.down: urlTextField
++                }
++                Bigscreen.TextField {
++                    id: urlTextField
++                    Layout.fillWidth: true
++                    placeholderText: i18n("URL")
++
++                    KeyNavigation.down: userAgentTextField
++                }
++                Bigscreen.TextField {
++                    id: userAgentTextField
++                    Layout.fillWidth: true
++                    placeholderText: i18n("User Agent")
++
++                    KeyNavigation.down: addWebAppDialog.footer
++                }
++            }
++        }
++
++        WebAppInfoSidebar {
++            id: delegateInfoDialog
++
++            property var delegate
++            onClosed: {
++                if (delegate) {
++                    delegate.forceActiveFocus();
++                } else {
++                    root.forceActiveFocus();
++                }
++            }
++        }
++    }
++}
+diff --git a/kcms/webapps/webappcreator.cpp b/kcms/webapps/webappcreator.cpp
+new file mode 100644
+index 0000000000000000000000000000000000000000..e95ad104af6373a69dcf29c8494ff1ff8c4c2989
+--- /dev/null
++++ b/kcms/webapps/webappcreator.cpp
+@@ -0,0 +1,102 @@
++// SPDX-FileCopyrightText: 2020-2021 Jonah Brüchert <jbb.prv at gmx.de>
++//
++// SPDX-License-Identifier: LGPL-2.0-or-later
++
++#include "webappcreator.h"
++#include "webappmanager.h"
++
++#include <QDebug>
++#include <QDir>
++#include <QFile>
++#include <QProcess>
++#include <QQmlEngine>
++#include <QQuickImageProvider>
++#include <QStandardPaths>
++
++#include <KConfigGroup>
++#include <KDesktopFile>
++
++#include <QCoroSignal>
++#include <QCoroTask>
++
++WebAppCreator::WebAppCreator(QObject *parent)
++    : QObject(parent)
++    , m_webAppMngr(WebAppManager::instance())
++{
++    connect(this, &WebAppCreator::websiteNameChanged, this, &WebAppCreator::existsChanged);
++    connect(&m_webAppMngr, &WebAppManager::applicationsChanged, this, &WebAppCreator::existsChanged);
++}
++
++bool WebAppCreator::exists() const
++{
++    return m_webAppMngr.exists(m_websiteName);
++}
++
++const QString &WebAppCreator::websiteName() const
++{
++    return m_websiteName;
++}
++
++void WebAppCreator::setWebsiteName(const QString &websiteName)
++{
++    m_websiteName = websiteName;
++    Q_EMIT websiteNameChanged();
++}
++
++QCoro::Task<> WebAppCreator::addEntry(const QString name, const QString url, const QString iconUrl, const QString &userAgent)
++{
++    // QPointer self = this;
++    // auto image = co_await fetchIcon(iconUrl);
++    // if (!self) {
++    //     co_return;
++    // }
++
++    // m_webAppMngr.addApp(name, url, image, userAgent);
++    m_webAppMngr.addApp(name, url, iconUrl, userAgent);
++
++    // Refresh homescreen entries
++    QProcess buildsycoca;
++    buildsycoca.setProgram(QStringLiteral("kbuildsycoca6"));
++    buildsycoca.startDetached();
++    co_return;
++}
++
++QCoro::QmlTask WebAppCreator::createDesktopFile(const QString name, QString url, QString icon, const QString &userAgent)
++{
++    return addEntry(name, url, icon, userAgent);
++}
++
++QCoro::Task<QImage> WebAppCreator::fetchIcon(const QString &url)
++{
++    auto *provider = static_cast<QQuickAsyncImageProvider *>(qmlEngine(this)->imageProvider(QStringLiteral("favicon")));
++    if (!provider) {
++        qDebug() << "Failed to access favicon provider";
++        co_return QImage();
++    }
++
++    const QStringView prefixFavicon = QStringView(u"image://favicon/");
++    const QString providerIconName = url.mid(prefixFavicon.size());
++
++    const QSize szRequested;
++
++    switch (provider->imageType()) {
++    case QQmlImageProviderBase::Image: {
++        co_return provider->requestImage(providerIconName, nullptr, szRequested);
++    }
++    case QQmlImageProviderBase::Pixmap: {
++        co_return provider->requestPixmap(providerIconName, nullptr, szRequested).toImage();
++    }
++    case QQmlImageProviderBase::Texture: {
++        co_return provider->requestTexture(providerIconName, nullptr, szRequested)->image();
++    }
++    case QQmlImageProviderBase::ImageResponse: {
++        auto response = provider->requestImageResponse(providerIconName, szRequested);
++        co_await qCoro(response, &QQuickImageResponse::finished);
++        co_return response->textureFactory()->image();
++    }
++    default:
++        qDebug() << "Failed to save unhandled image type";
++    }
++
++    co_return QImage();
++}
+diff --git a/kcms/webapps/webappcreator.h b/kcms/webapps/webappcreator.h
+new file mode 100644
+index 0000000000000000000000000000000000000000..3057d28e0d0d8eb20546b213af9c4c753d3b5006
+--- /dev/null
++++ b/kcms/webapps/webappcreator.h
+@@ -0,0 +1,40 @@
++// SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb at kaidan.im>
++//
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#pragma once
++
++#include <QCoroQml>
++#include <QCoroTask>
++#include <QObject>
++#include <qqmlregistration.h>
++
++class QQmlEngine;
++class WebAppManager;
++
++class WebAppCreator : public QObject
++{
++    Q_OBJECT
++    QML_ELEMENT
++
++    Q_PROPERTY(QString websiteName READ websiteName WRITE setWebsiteName NOTIFY websiteNameChanged)
++    Q_PROPERTY(bool exists READ exists NOTIFY existsChanged)
++
++public:
++    explicit WebAppCreator(QObject *parent = nullptr);
++
++    const QString &websiteName() const;
++    void setWebsiteName(const QString &websiteName);
++    Q_SIGNAL void websiteNameChanged();
++
++    bool exists() const;
++    Q_SIGNAL void existsChanged();
++
++    Q_INVOKABLE QCoro::Task<> addEntry(const QString name, const QString url, const QString icon, const QString &userAgent);
++    Q_INVOKABLE QCoro::QmlTask createDesktopFile(const QString name, QString url, QString icon, const QString &userAgent);
++
++private:
++    QString m_websiteName;
++    QCoro::Task<QImage> fetchIcon(const QString &url);
++    WebAppManager &m_webAppMngr;
++};
+diff --git a/kcms/webapps/webappmanager.cpp b/kcms/webapps/webappmanager.cpp
+new file mode 100644
+index 0000000000000000000000000000000000000000..6377cd5f3c958b007eca7c206e930c14200dfbfd
+--- /dev/null
++++ b/kcms/webapps/webappmanager.cpp
+@@ -0,0 +1,166 @@
++// SPDX-FileCopyrightText: 2021 Jonah Brüchert <jbb at kaidan.im>
++//
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#include "webappmanager.h"
++
++#include <QImage>
++#include <QStandardPaths>
++#include <QStringBuilder>
++
++#include <KConfigGroup>
++#include <KDesktopFile>
++#include <KSandbox>
++
++const QString USERAGENT_CFG_KEY = QStringLiteral("X-KDE-Bigscreen-UserAgent");
++
++WebAppManager::WebAppManager(QObject *parent)
++    : QObject(parent)
++    , m_desktopFileDirectory(desktopFileDirectory())
++{
++    const auto fileInfos = m_desktopFileDirectory.entryInfoList(QDir::Files);
++
++    // Likely almost all files in the directory are webapps, so this should be worth it
++    m_webApps.reserve(fileInfos.size());
++
++    for (const auto &file : fileInfos) {
++        // Make sure to only parse desktop files
++        if (file.fileName().contains(QStringView(u".desktop"))) {
++            KDesktopFile desktopFile(file.filePath());
++
++            auto configGroup = desktopFile.group(QStringLiteral("Desktop Entry"));
++
++            // Only handle desktop files referencing plasma-bigscreen
++            if (configGroup.readEntry("Exec").contains(QStringView(u"plasma-bigscreen-webapp"))) {
++                auto userAgent = configGroup.readEntry(USERAGENT_CFG_KEY);
++                WebApp app{desktopFile.readName(), desktopFile.readIcon(), desktopFile.readUrl(), userAgent};
++                m_webApps.push_back(std::move(app));
++            }
++        }
++    }
++}
++
++QString WebAppManager::desktopFileDirectory()
++{
++    auto dir = []() -> QString {
++        if (KSandbox::isFlatpak()) {
++            return qEnvironmentVariable("HOME") % u"/.local/share/applications/";
++        }
++        return QStandardPaths::writableLocation(QStandardPaths::ApplicationsLocation);
++    }();
++
++    QDir(dir).mkpath(QStringLiteral("."));
++
++    return dir;
++}
++
++QString WebAppManager::iconDirectory()
++{
++    auto dir = []() -> QString {
++        if (KSandbox::isFlatpak()) {
++            return qEnvironmentVariable("HOME") % u"/.local/share/icons/hicolor/16x16/apps/";
++        }
++        return QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QStringLiteral("/icons/hicolor/16x16/apps/");
++    }();
++    QDir(dir).mkpath(QStringLiteral("."));
++
++    return dir;
++}
++
++const std::vector<WebApp> &WebAppManager::applications() const
++{
++    return m_webApps;
++}
++
++void WebAppManager::addApp(const QString &name, const QString &url, const QImage &icon, const QString &userAgent)
++{
++    const QString filename = generateFileName(name);
++
++    icon.save(iconDirectory() % QDir::separator() % filename % u".png", "PNG");
++    addApp(name, url, filename, userAgent);
++}
++
++void WebAppManager::addApp(const QString &name, const QString &url, const QString &iconFileName, const QString &userAgent)
++{
++    const QString filename = generateFileName(name);
++    const QString desktopFileName = generateDesktopFileName(name);
++
++    KConfig desktopFile(desktopFileDirectory() % QDir::separator() % desktopFileName, KConfig::SimpleConfig);
++
++    // TODO: maybe have program read options from .desktop file?
++    // Currently, the user can inject and break the launch command
++    QString exec = webAppCommand() + " --name \"" + name + "\"";
++    if (!userAgent.isEmpty()) {
++        exec += " --agent \"" + userAgent + "\"";
++    }
++    exec += " \"" + url + "\"";
++
++    auto desktopEntry = desktopFile.group(QStringLiteral("Desktop Entry"));
++    desktopEntry.writeEntry(QStringLiteral("Type"), QStringLiteral("Application"));
++    desktopEntry.writeEntry(QStringLiteral("URL"), url);
++    desktopEntry.writeEntry(QStringLiteral("Name"), name);
++    desktopEntry.writeEntry(QStringLiteral("Exec"), exec);
++    desktopEntry.writeEntry(QStringLiteral("Icon"), iconFileName);
++    desktopEntry.writeEntry(USERAGENT_CFG_KEY, userAgent);
++
++    m_webApps.push_back(WebApp{name, iconFileName, url, userAgent});
++
++    desktopFile.sync();
++
++    Q_EMIT applicationsChanged();
++}
++
++bool WebAppManager::exists(const QString &name)
++{
++    const QString location = desktopFileDirectory();
++    const QString filename = generateDesktopFileName(name);
++
++    return QFile::exists(location % QDir::separator() % filename);
++}
++
++bool WebAppManager::removeApp(const QString &name)
++{
++    const QString location = desktopFileDirectory();
++    const QString filename = generateDesktopFileName(name);
++
++    auto it = std::remove_if(m_webApps.begin(), m_webApps.end(), [&name](const WebApp &app) {
++        return app.name == name;
++    });
++
++    m_webApps.erase(it);
++
++    bool success = QFile::remove(location % QDir::separator() % filename);
++    Q_EMIT applicationsChanged();
++    return success;
++}
++
++WebAppManager &WebAppManager::instance()
++{
++    static WebAppManager instance;
++    return instance;
++}
++
++QString WebAppManager::generateFileName(const QString &name)
++{
++    QString filename = name.toLower();
++    filename.replace(QChar(u' '), QChar(u'_'));
++    filename.remove(u'/');
++    filename.remove(u'"');
++    filename.remove(u'\'');
++    filename.remove(u',');
++    filename.remove(u'.');
++    filename.remove(u'|');
++    return filename;
++}
++
++QString WebAppManager::generateDesktopFileName(const QString &name)
++{
++    return generateFileName(name) % u".desktop";
++}
++
++QString WebAppManager::webAppCommand()
++{
++    return QStringLiteral("plasma-bigscreen-webapp");
++}
++
++#include "moc_webappmanager.cpp"
+diff --git a/kcms/webapps/webappmanager.h b/kcms/webapps/webappmanager.h
+new file mode 100644
+index 0000000000000000000000000000000000000000..764ebe5563d513dd5171cccc70b33222cddb0b0a
+--- /dev/null
++++ b/kcms/webapps/webappmanager.h
+@@ -0,0 +1,48 @@
++// SPDX-FileCopyrightText: 2021 Jonah Brüchert <jbb at kaidan.im>
++//
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#pragma once
++
++#include <QDir>
++#include <QObject>
++
++#include <memory>
++
++struct WebApp {
++    QString name;
++    QString icon;
++    QString url;
++    QString userAgent;
++};
++
++class WebAppManager : public QObject
++{
++    Q_OBJECT
++
++public:
++    explicit WebAppManager(QObject *parent = nullptr);
++
++    static QString desktopFileDirectory();
++    static QString iconDirectory();
++    const std::vector<WebApp> &applications() const;
++
++    void addApp(const QString &name, const QString &url, const QImage &icon, const QString &userAgent);
++    void addApp(const QString &name, const QString &url, const QString &iconFileName, const QString &userAgent);
++    bool exists(const QString &name);
++    bool removeApp(const QString &name);
++
++    static WebAppManager &instance();
++
++Q_SIGNALS:
++    void applicationsChanged();
++
++private:
++    static QString generateFileName(const QString &name);
++    static QString generateDesktopFileName(const QString &name);
++    static QString webAppCommand();
++
++private:
++    QDir m_desktopFileDirectory;
++    std::vector<WebApp> m_webApps;
++};
+diff --git a/kcms/webapps/webappmanagermodel.cpp b/kcms/webapps/webappmanagermodel.cpp
+new file mode 100644
+index 0000000000000000000000000000000000000000..3799ebaad5991d969add2fc6610397e237f35d81
+--- /dev/null
++++ b/kcms/webapps/webappmanagermodel.cpp
+@@ -0,0 +1,58 @@
++// SPDX-FileCopyrightText: 2021 Jonah Brüchert <jbb at kaidan.im>
++//
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#include "webappmanagermodel.h"
++
++#include "webappmanager.h"
++
++WebAppManagerModel::WebAppManagerModel(QObject *parent)
++    : QAbstractListModel(parent)
++    , m_webAppMngr(WebAppManager::instance())
++{
++}
++
++WebAppManagerModel::~WebAppManagerModel() = default;
++
++int WebAppManagerModel::rowCount(const QModelIndex &index) const
++{
++    return index.isValid() ? 0 : int(m_webAppMngr.applications().size());
++}
++
++QVariant WebAppManagerModel::data(const QModelIndex &index, int role) const
++{
++    switch (role) {
++    case Role::NameRole:
++        return m_webAppMngr.applications()[index.row()].name;
++    case Role::IconRole:
++        return m_webAppMngr.applications()[index.row()].icon;
++        // return QString(WebAppManager::iconDirectory() + QString(QDir::separator()) + m_webAppMngr.applications()[index.row()].icon);
++    case Role::UrlRole:
++        return m_webAppMngr.applications()[index.row()].url;
++    case Role::UserAgentRole:
++        return m_webAppMngr.applications()[index.row()].userAgent;
++    }
++
++    Q_UNREACHABLE();
++
++    return {};
++}
++
++QHash<int, QByteArray> WebAppManagerModel::roleNames() const
++{
++    return {
++        {Role::NameRole, QByteArrayLiteral("name")},
++        {Role::IconRole, QByteArrayLiteral("desktopIcon")},
++        {Role::UrlRole, QByteArrayLiteral("url")},
++        {Role::UserAgentRole, QByteArrayLiteral("userAgent")},
++    };
++}
++
++void WebAppManagerModel::removeApp(int index)
++{
++    beginRemoveRows({}, index, index);
++    m_webAppMngr.removeApp(m_webAppMngr.applications()[index].name);
++    endRemoveRows();
++}
++
++#include "moc_webappmanagermodel.cpp"
+diff --git a/kcms/webapps/webappmanagermodel.h b/kcms/webapps/webappmanagermodel.h
+new file mode 100644
+index 0000000000000000000000000000000000000000..5f350d461076d3854d826b8782495d09cb43eb2d
+--- /dev/null
++++ b/kcms/webapps/webappmanagermodel.h
+@@ -0,0 +1,37 @@
++// SPDX-FileCopyrightText: 2021 Jonah Brüchert <jbb at kaidan.im>
++//
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#pragma once
++
++#include <QAbstractListModel>
++
++#include <qqmlregistration.h>
++
++class WebAppManager;
++
++class WebAppManagerModel : public QAbstractListModel
++{
++    Q_OBJECT
++    QML_ELEMENT
++
++    enum Role {
++        NameRole,
++        IconRole,
++        UrlRole,
++        UserAgentRole
++    };
++
++public:
++    explicit WebAppManagerModel(QObject *parent = nullptr);
++    ~WebAppManagerModel();
++
++    int rowCount(const QModelIndex &index) const override;
++    QVariant data(const QModelIndex &index, int role) const override;
++    QHash<int, QByteArray> roleNames() const override;
++
++    Q_INVOKABLE void removeApp(int index);
++
++private:
++    WebAppManager &m_webAppMngr;
++};
+diff --git a/kcms/webapps/webappskcm.cpp b/kcms/webapps/webappskcm.cpp
+new file mode 100644
+index 0000000000000000000000000000000000000000..be7c55b66f73ce7d946c0d3785278a9686de751e
+--- /dev/null
++++ b/kcms/webapps/webappskcm.cpp
+@@ -0,0 +1,32 @@
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#include "webappskcm.h"
++
++#include "webappcreator.h"
++#include "webappmanager.h"
++#include "webappmanagermodel.h"
++
++WebAppsKCM::WebAppsKCM(QObject *parent, const KPluginMetaData &data)
++    : KQuickConfigModule(parent, data)
++{
++    WebAppManager::instance();
++    WebAppManagerModel *model = new WebAppManagerModel{this};
++
++    qmlRegisterSingletonType<WebAppManager>("org.kde.bigscreen.webappskcm", 1, 0, "WebAppManager", [this](QQmlEngine *, QJSEngine *) -> QObject * {
++        return new WebAppManager{this};
++    });
++    qmlRegisterSingletonType<WebAppCreator>("org.kde.bigscreen.webappskcm", 1, 0, "WebAppCreator", [this](QQmlEngine *, QJSEngine *) -> QObject * {
++        return new WebAppCreator{this};
++    });
++    qmlRegisterSingletonType<WebAppManagerModel>("org.kde.bigscreen.webappskcm", 1, 0, "WebAppManagerModel", [this](QQmlEngine *, QJSEngine *) -> QObject * {
++        return new WebAppManagerModel{this};
++    });
++}
++
++WebAppsKCM::~WebAppsKCM() = default;
++
++K_PLUGIN_CLASS_WITH_JSON(WebAppsKCM, "kcm_mediacenter_webapps.json")
++
++#include "moc_webappskcm.cpp"
++#include "webappskcm.moc"
+diff --git a/kcms/webapps/webappskcm.h b/kcms/webapps/webappskcm.h
+new file mode 100644
+index 0000000000000000000000000000000000000000..45768eb7899cf1b69e5d1c2979d89cecc882db7e
+--- /dev/null
++++ b/kcms/webapps/webappskcm.h
+@@ -0,0 +1,23 @@
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#pragma once
++
++#include <KQuickConfigModule>
++#include <QObject>
++#include <QVariant>
++
++class WebAppsKCM : public KQuickConfigModule
++{
++    Q_OBJECT
++
++public:
++    WebAppsKCM(QObject *parent, const KPluginMetaData &data);
++    ~WebAppsKCM() override;
++
++public Q_SLOTS:
++
++Q_SIGNALS:
++
++private:
++};
+diff --git a/webapp-viewer/CMakeLists.txt b/webapp-viewer/CMakeLists.txt
+new file mode 100644
+index 0000000000000000000000000000000000000000..24c75d95678084ebac7d71967380a7b0aaf531f4
+--- /dev/null
++++ b/webapp-viewer/CMakeLists.txt
+@@ -0,0 +1,43 @@
++# SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb at kaidan.im>
++# SPDX-FileCopyrightText: 2020 Rinigus <rinigus.git at gmail.com>
++# SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++# SPDX-License-Identifier: LGPL-2.0-or-later
++
++add_executable(plasma-bigscreen-webapp
++    main.cpp
++    useragent.cpp
++    browsermanager.cpp
++    webprofile.cpp
++)
++
++ecm_add_qml_module(plasma-bigscreen-webapp-plugin URI org.kde.bigscreen.webapp.sources GENERATE_PLUGIN_SOURCE DEPENDENCIES QtQuick)
++
++# Include qml and js files within ./qml/
++file(GLOB_RECURSE _qml_sources
++    "qml/*.qml"
++    "qml/*.js"
++)
++ecm_target_qml_sources(plasma-bigscreen-webapp-plugin SOURCES ${_qml_sources})
++ecm_finalize_qml_module(plasma-bigscreen-webapp-plugin)
++
++ecm_add_qml_module(plasma-bigscreen-webapp
++    GENERATE_PLUGIN_SOURCE
++    URI org.kde.bigscreen.webapp
++)
++
++target_compile_definitions(plasma-bigscreen-webapp PRIVATE -DQT_NO_CAST_FROM_ASCII)
++target_link_libraries(plasma-bigscreen-webapp PRIVATE
++    Qt::Core
++    Qt::Qml
++    Qt::Quick
++    Qt::Widgets
++    KF6::I18n
++    KF6::CoreAddons
++    KF6::Notifications
++)
++
++target_link_libraries(plasma-bigscreen-webapp PRIVATE Qt::WebEngineCore Qt::WebEngineQuick)
++
++target_include_directories(plasma-bigscreen-webapp PRIVATE ${CMAKE_BINARY_DIR})
++install(TARGETS plasma-bigscreen-webapp ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
++
+diff --git a/webapp-viewer/browsermanager.cpp b/webapp-viewer/browsermanager.cpp
+new file mode 100644
+index 0000000000000000000000000000000000000000..b866ef2c4ecc18d20de943d01d3f72e04b25d518
+--- /dev/null
++++ b/webapp-viewer/browsermanager.cpp
+@@ -0,0 +1,77 @@
++// SPDX-FileCopyrightText: 2014 Sebastian Kügler <sebas at kde.org>
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#include "browsermanager.h"
++
++#include <QDebug>
++#include <QQmlEngine>
++#include <QSettings>
++#include <QStandardPaths>
++#include <QUrl>
++
++BrowserManager *BrowserManager::s_instance = nullptr;
++
++BrowserManager::BrowserManager(QObject *parent)
++    : QObject(parent)
++{
++}
++
++void BrowserManager::addToHistory(const QVariantMap &pagedata)
++{
++    // m_dbmanager->addToHistory(pagedata);
++}
++
++void BrowserManager::removeFromHistory(const QString &url)
++{
++    // m_dbmanager->removeFromHistory(url);
++}
++
++void BrowserManager::clearHistory()
++{
++    // m_dbmanager->clearHistory();
++}
++
++void BrowserManager::updateLastVisited(const QString &url)
++{
++    // m_dbmanager->updateLastVisited(url);
++}
++
++void BrowserManager::updateIcon(const QString &url, const QString &iconSource)
++{
++    auto *engine = qmlEngine(this);
++    Q_ASSERT(engine);
++    // TODO
++    // m_dbmanager->updateIcon(engine, url, iconSource);
++}
++
++QUrl BrowserManager::initialUrl() const
++{
++    return m_initialUrl;
++}
++
++QString BrowserManager::tempDirectory() const
++{
++    return QStandardPaths::writableLocation(QStandardPaths::TempLocation);
++}
++
++QString BrowserManager::downloadDirectory() const
++{
++    return QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
++}
++
++void BrowserManager::setInitialUrl(const QUrl &initialUrl)
++{
++    m_initialUrl = initialUrl;
++    Q_EMIT initialUrlChanged();
++}
++
++BrowserManager *BrowserManager::instance()
++{
++    if (!s_instance)
++        s_instance = new BrowserManager();
++
++    return s_instance;
++}
++
++#include "moc_browsermanager.cpp"
+diff --git a/webapp-viewer/browsermanager.h b/webapp-viewer/browsermanager.h
+new file mode 100644
+index 0000000000000000000000000000000000000000..696275cb70497d82b9b67f2241047849d4243e4e
+--- /dev/null
++++ b/webapp-viewer/browsermanager.h
+@@ -0,0 +1,47 @@
++
++// SPDX-FileCopyrightText: 2014 Sebastian Kügler <sebas at kde.org>
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#pragma once
++
++#include <QJSEngine>
++#include <QObject>
++#include <QQmlEngine>
++#include <QUrl>
++#include <QtQml/qqmlregistration.h>
++
++class BrowserManager : public QObject
++{
++    Q_OBJECT
++    Q_PROPERTY(QUrl initialUrl READ initialUrl WRITE setInitialUrl NOTIFY initialUrlChanged)
++    QML_ELEMENT
++    QML_SINGLETON
++
++public:
++    static BrowserManager *instance();
++    static BrowserManager *create(QQmlEngine *, QJSEngine *)
++    {
++        return BrowserManager::instance();
++    }
++    QUrl initialUrl() const;
++    void setInitialUrl(const QUrl &initialUrl);
++
++Q_SIGNALS:
++    void updated();
++    void initialUrlChanged();
++
++public Q_SLOTS:
++    void addToHistory(const QVariantMap &pagedata);
++    void removeFromHistory(const QString &url);
++    void clearHistory();
++    void updateLastVisited(const QString &url);
++    void updateIcon(const QString &url, const QString &iconSource);
++    QString tempDirectory() const;
++    QString downloadDirectory() const;
++
++private:
++    BrowserManager(QObject *parent = nullptr);
++    QUrl m_initialUrl;
++    static BrowserManager *s_instance;
++};
+diff --git a/webapp-viewer/main.cpp b/webapp-viewer/main.cpp
+new file mode 100644
+index 0000000000000000000000000000000000000000..ce1fda6d1350138390702d83830b7081844a9f44
+--- /dev/null
++++ b/webapp-viewer/main.cpp
+@@ -0,0 +1,81 @@
++// SPDX-FileCopyrightText: 2019 Jonah Brüchert <jbb.prv at gmx.de>
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++// SPDX-License-Identifier: LGPL-2.0-or-later
++
++#include <QApplication>
++#include <QCommandLineOption>
++#include <QCommandLineParser>
++#include <QIcon>
++#include <QQmlApplicationEngine>
++#include <QUrl>
++#include <QtQml>
++#include <QtWebEngineQuick>
++
++#include <KAboutData>
++#include <KLocalizedContext>
++#include <KLocalizedQmlContext>
++#include <KLocalizedString>
++
++#include "browsermanager.h"
++#include "useragent.h"
++
++constexpr auto APPLICATION_ID = "org.kde.angelfish";
++
++int main(int argc, char *argv[])
++{
++    QGuiApplication::setAttribute(Qt::AA_ShareOpenGLContexts);
++
++    KLocalizedString::setApplicationDomain("plasma-bigscreen");
++
++    // Setup QtWebEngine
++    qputenv("QTWEBENGINE_DIALOG_SET", "QtQuickControls2");
++    QtWebEngineQuick::initialize();
++
++    QApplication app(argc, argv);
++
++    // Command line parser
++    QCommandLineOption agentOption{QStringLiteral("agent"),
++                                   i18n("The user agent to browse with."),
++                                   QStringLiteral("Mozilla/5.0 (Linux; Android 12) Cobalt/22.2.3-gold (PS4)")};
++    QCommandLineOption nameOption{QStringLiteral("name"), i18n("The name of the web app"), QStringLiteral("webapp")};
++
++    QCommandLineParser parser;
++    parser.addPositionalArgument(QStringLiteral("link"), i18n("link of website to launch"), QStringLiteral("[link]"));
++    parser.addOption(agentOption);
++    parser.addOption(nameOption);
++    parser.addHelpOption();
++    parser.process(app);
++
++    if (parser.positionalArguments().isEmpty()) {
++        return 1;
++    }
++
++    const QString link = parser.positionalArguments().constFirst();
++    const QUrl initialUrl = QUrl::fromUserInput(link);
++
++    KAboutData aboutData(QStringLiteral("plasma-bigscreen-webapp"),
++                         parser.isSet(nameOption) ? parser.value(nameOption) : i18n("Webview"),
++                         QStringLiteral("0.1"),
++                         i18n("Plasma Bigscreen Webapp runtime"),
++                         KAboutLicense::GPL,
++                         i18n("Copyright 2025 Plasma developers"));
++    QApplication::setWindowIcon(QIcon::fromTheme(QStringLiteral()));
++
++    KAboutData::setApplicationData(aboutData);
++
++    BrowserManager::instance()->setInitialUrl(initialUrl);
++
++    // QML loading
++    QQmlApplicationEngine engine;
++
++    engine.setInitialProperties({{QStringLiteral("userAgent"), parser.isSet(agentOption) ? parser.value(agentOption) : QString{}}});
++    engine.rootContext()->setContextObject(new KLocalizedContext(&engine));
++    engine.loadFromModule(QStringLiteral("org.kde.bigscreen.webapp.sources"), QStringLiteral("WebApp"));
++
++    // Error handling
++    if (engine.rootObjects().isEmpty()) {
++        return -1;
++    }
++
++    return app.exec();
++}
+diff --git a/webapp-viewer/qml/WebApp.qml b/webapp-viewer/qml/WebApp.qml
+new file mode 100644
+index 0000000000000000000000000000000000000000..7b4a42ee0f079b290e386324e39444d66991ab27
+--- /dev/null
++++ b/webapp-viewer/qml/WebApp.qml
+@@ -0,0 +1,79 @@
++// SPDX-FileCopyrightText: 2014-2015 Sebastian Kügler <sebas at kde.org>
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++import QtQuick
++import QtQuick.Window
++import QtQuick.Layouts
++import QtWebEngine
++
++import org.kde.kirigami as Kirigami
++import org.kde.bigscreen.webapp
++
++Kirigami.ApplicationWindow {
++    id: webBrowser
++    title: currentWebView.title
++
++    required property string userAgent
++
++    minimumWidth: Kirigami.Units.gridUnit * 15
++    minimumHeight: Kirigami.Units.gridUnit * 15
++
++    pageStack.globalToolBar.showNavigationButtons: Kirigami.ApplicationHeaderStyle.NoNavigationButtons
++    pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.None
++
++    // Main Page
++    pageStack.initialPage: Kirigami.Page {
++        id: rootPage
++        leftPadding: 0
++        rightPadding: 0
++        topPadding: 0
++        bottomPadding: 0
++        globalToolBarStyle: Kirigami.ApplicationHeaderStyle.None
++
++        Kirigami.ColumnView.fillWidth: true
++        Kirigami.ColumnView.pinned: true
++        Kirigami.ColumnView.preventStealing: true
++
++        WebAppView {
++            // ID for compatibility with components
++            id: currentWebView
++            anchors.fill: parent
++            url: BrowserManager.initialUrl
++            userAgent: webBrowser.userAgent
++        }
++
++        // Container for the progress bar
++        Item {
++            id: progressItem
++
++            height: Math.round(Kirigami.Units.gridUnit / 6)
++            z: 99
++            anchors {
++                bottom: parent.bottom
++                bottomMargin: -Math.round(height / 2)
++                left: webBrowser.left
++                right: webBrowser.right
++            }
++
++            opacity: currentWebView.loading ? 1 : 0
++            Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad; } }
++
++            Rectangle {
++                color: Kirigami.Theme.highlightColor
++
++                width: Math.round((currentWebView.loadProgress / 100) * parent.width)
++                anchors {
++                    top: parent.top
++                    left: parent.left
++                    bottom: parent.bottom
++                }
++            }
++        }
++
++        Loader {
++            id: sheetLoader
++        }
++    }
++}
++
+diff --git a/webapp-viewer/qml/WebAppView.qml b/webapp-viewer/qml/WebAppView.qml
+new file mode 100644
+index 0000000000000000000000000000000000000000..6f851d41741cd83a947d59dbacb57d5f9dfb571b
+--- /dev/null
++++ b/webapp-viewer/qml/WebAppView.qml
+@@ -0,0 +1,31 @@
++// SPDX-FileCopyrightText: 2014-2015 Sebastian Kügler <sebas at kde.org>
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++import QtQuick
++import QtQuick.Controls as QQC2
++import QtQuick.Layouts
++
++import org.kde.kirigami as Kirigami
++import org.kde.bigscreen.webapp as WebApp
++
++WebView {
++    id: webEngineView
++    property string userAgent
++
++    onUserAgentChanged: WebApp.UserAgent.userAgent = userAgent
++
++    profile: WebApp.WebProfile {
++        httpUserAgent: WebApp.UserAgent.userAgent
++        offTheRecord: false
++        storageName: "plasma-bigscreen-webapp"
++
++        onHttpUserAgentChanged: console.log("User agent set: " + httpUserAgent)
++    }
++
++    isAppView: true
++
++    onNewWindowRequested: {
++        Qt.openUrlExternally(request.requestedUrl);
++    }
++}
+diff --git a/webapp-viewer/qml/WebView.qml b/webapp-viewer/qml/WebView.qml
+new file mode 100644
+index 0000000000000000000000000000000000000000..2c5ab8d9da5a3019e6643403d1721fdcb1451990
+--- /dev/null
++++ b/webapp-viewer/qml/WebView.qml
+@@ -0,0 +1,461 @@
++// SPDX-FileCopyrightText: 2014-2015 Sebastian Kügler <sebas at kde.org>
++//
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++import QtQuick
++import QtQuick.Controls as QQC2
++import QtQuick.Layouts
++import QtWebEngine
++
++import org.kde.kirigami as Kirigami
++
++import org.kde.bigscreen.webapp as WebApp
++
++WebEngineView {
++    id: webEngineView
++
++    // int, but we want nullability
++    property var errorCode: null
++    property var errorDomain: null
++    property string errorString: ""
++
++    property bool privateMode: false
++
++    property var userAgent: WebApp.UserAgent
++
++    // loadingActive property is set to true when loading is started
++    // and turned to false only after succesful or failed loading. It
++    // is possible to set it to false by calling stopLoading method.
++    //
++    // The property was introduced as it triggers visibility of the webEngineView
++    // in the other parts of the code. When using loading that is linked
++    // to visibility, stop/start loading was observed in some conditions. It looked as if
++    // there is an internal optimization of webengine in the case of parallel
++    // loading of several pages that could use visibility as one of the decision
++    // making parameters.
++    property bool loadingActive: false
++
++    // reloadOnVisible property ensures that the view has been always
++    // loaded at least once while it is visible. When the view is loaded
++    // while visible is set to false, there, what appears to be Chromium
++    // optimizations that can disturb the loading.
++    property bool reloadOnVisible: true
++
++    // Profiles of WebViews are shared among all views of the same type (regular or
++    // private). However, within each group of tabs, we can have some tabs that are
++    // using mobile or desktop user agent. To avoid loading a page with the wrong
++    // user agent, the agent is checked in the beginning of the loading at onLoadingChanged
++    // handler. If the user agent is wrong, loading is stopped and reloadOnMatchingAgents
++    // property is set to true. As soon as the agent is correct, the page is loaded.
++    property bool reloadOnMatchingAgents: false
++
++    // Used to follow whether agents match
++    property bool agentsMatch: profile.httpUserAgent === userAgent.userAgent
++
++    // URL that was requested and should be used
++    // as a base for user interaction. It reflects
++    // last request (successful or failed)
++    property url requestedUrl: url
++
++    property int findInPageResultIndex
++    property int findInPageResultCount
++
++    // Used to hide certain context menu items
++    property bool isAppView: false
++
++    // url to keep last url to return from reader mode
++    property url readerSourceUrl
++
++    // string to keep last title to return from reader mode
++    property string readerTitle
++
++    // Used for pdf generated to preview before print
++    property url printPreviewUrl: ""
++    property bool generatingPdf: false
++    property int printedPageOrientation: WebEngineView.Portrait
++    property int printedPageSizeId: WebEngineView.A4
++
++    Shortcut {
++        enabled: webEngineView.isFullScreen
++        sequence: "Esc"
++        onActivated: webEngineView.fullScreenCancelled();
++    }
++
++    settings {
++        autoLoadImages: true
++        javascriptEnabled: true
++        // Disable builtin error pages in favor of our own
++        errorPageEnabled: false
++        // Load larger touch icons
++        touchIconsEnabled: true
++        // Disable scrollbars on mobile
++        showScrollBars: true
++        // Generally allow screen sharing, still needs permission from the user
++        screenCaptureEnabled: true
++        // Enables a web page to request that one of its HTML elements be made to occupy the user's entire screen
++        fullScreenSupportEnabled: true
++        // Turns on printing of CSS backgrounds when printing a web page
++        printElementBackgrounds: false
++    }
++
++    focus: true
++    onLoadingChanged: loadRequest => {
++        print("    url: " + loadRequest.url + " " + loadRequest.status)
++
++        /* Handle
++        *  - WebEngineView::LoadStartedStatus,
++        *  - WebEngineView::LoadStoppedStatus,
++        *  - WebEngineView::LoadSucceededStatus and
++        *  - WebEngineView::LoadFailedStatus
++        */
++        var ec = null;
++        var es = "";
++        var ed = null;
++        if (loadRequest.status === WebEngineView.LoadStartedStatus) {
++            if (profile.httpUserAgent !== userAgent.userAgent) {
++                //print("Mismatch of user agents, will load later " + loadRequest.url);
++                reloadOnMatchingAgents = true;
++                stopLoading();
++            } else {
++                loadingActive = true;
++            }
++        }
++        if (loadRequest.status === WebEngineView.LoadSucceededStatus) {
++            if (!privateMode) {
++                const request = {
++                    url: currentWebView.url,
++                    title: currentWebView.title,
++                    icon: currentWebView.icon
++                }
++
++                WebApp.BrowserManager.addToHistory(request);
++                WebApp.BrowserManager.updateLastVisited(currentWebView.url);
++            }
++
++            ec = null;
++            es = "";
++            ed = null;
++            loadingActive = false;
++        }
++        if (loadRequest.status === WebEngineView.LoadFailedStatus) {
++            print("Load failed: " + loadRequest.errorCode + " " + loadRequest.errorString);
++            print("Load failed url: " + loadRequest.url + " " + url);
++            ec = loadRequest.errorCode;
++            es = loadRequest.errorString;
++            ed = loadRequest.errorDomain
++            loadingActive = false;
++
++            // update requested URL only after its clear that it fails.
++            // Otherwise, its updated as a part of url property update.
++            if (requestedUrl !== loadRequest.url)
++                requestedUrl = loadRequest.url;
++        }
++        errorCode = ec;
++        errorDomain = ed;
++        errorString = es;
++    }
++
++    Component.onCompleted: {
++        print("WebView completed.");
++        print("Settings: " + webEngineView.settings);
++    }
++
++    onIconChanged: {
++        if (icon && !privateMode) {
++            WebApp.BrowserManager.updateIcon(url, icon)
++        }
++    }
++    onNewWindowRequested: request => {
++        // Just open in a browser for now
++        Qt.openUrlExternally(request.requestedUrl.toString());
++
++        // // If a new window is requested, just open it
++        // if (request.userInitiated) {
++        //     tabsModel.newTab(request.requestedUrl.toString())
++        //     showPassiveNotification(i18nc("@info:status", "Website was opened in a new tab"))
++        // } else {
++        //     // TODO: should we allow user input?
++        //     questionLoader.setSource("NewTabQuestion.qml")
++        //     questionLoader.item.url = request.requestedUrl
++        //     questionLoader.item.visible = true
++        // }
++    }
++    onUrlChanged: {
++        if (requestedUrl !== url) {
++            requestedUrl = url;
++        }
++    }
++
++    onFullScreenRequested: request => {
++        if (request.toggleOn) {
++            webBrowser.showFullScreen()
++            const message = i18nc("@info:status", "Entered Full Screen mode")
++            const actionText = i18nc("@action:button", "Exit Full Screen (Esc)")
++            showPassiveNotification(message, "short", actionText, function() { webEngineView.fullScreenCancelled() });
++        } else {
++            webBrowser.showNormal()
++        }
++
++        request.accept()
++    }
++
++    onContextMenuRequested: request => {
++        request.accepted = true // Make sure QtWebEngine doesn't show its own context menu.
++        contextMenu.request = request
++        contextMenu.x = request.position.x
++        contextMenu.y = request.position.y
++        contextMenu.open()
++    }
++
++    onAuthenticationDialogRequested: request => {
++        request.accepted = true
++        sheetLoader.setSource("AuthSheet.qml")
++        sheetLoader.item.request = request
++        sheetLoader.item.open()
++    }
++
++    onFeaturePermissionRequested: (securityOrigin, feature) => {
++        let newQuestion = rootPage.questions.newPermissionQuestion()
++        newQuestion.permission = feature
++        newQuestion.origin = securityOrigin
++        newQuestion.visible = true
++    }
++
++    onJavaScriptDialogRequested: request => {
++        request.accepted = true;
++        sheetLoader.setSource("JavaScriptDialogSheet.qml");
++        sheetLoader.item.request = request;
++        sheetLoader.item.open();
++    }
++
++    onFindTextFinished: result => {
++        findInPageResultIndex = result.activeMatch;
++        findInPageResultCount = result.numberOfMatches;
++    }
++
++    onVisibleChanged: {
++        if (visible && reloadOnVisible) {
++            // see description of reloadOnVisible above for reasoning
++            reloadOnVisible = false;
++            reload();
++        }
++    }
++
++    onAgentsMatchChanged: {
++        if (agentsMatch && reloadOnMatchingAgents) {
++            // see description of reloadOnMatchingAgents above for reasoning
++            reloadOnMatchingAgents = false;
++            reload();
++        }
++    }
++
++    onCertificateError: error => {
++        error.defer();
++        errorHandler.enqueue(error);
++    }
++
++    function findInPageForward(text) {
++        findText(text);
++    }
++
++    function stopLoading() {
++        loadingActive = false;
++        stop();
++    }
++
++    onLinkHovered: hoveredUrl => hoveredLink.text = hoveredUrl
++
++    QQC2.Label {
++        id: hoveredLink
++        visible: text.length > 0
++        z: 2
++        anchors.bottom: parent.bottom
++        anchors.left: parent.left
++        leftPadding: Kirigami.Units.smallSpacing
++        rightPadding: Kirigami.Units.smallSpacing
++        color: Kirigami.Theme.textColor
++        font.pointSize: Kirigami.Theme.defaultFont.pointSize - 1
++
++        background: Rectangle {
++            anchors.fill: parent
++            color: Kirigami.Theme.backgroundColor
++        }
++    }
++
++    QQC2.Menu {
++        id: contextMenu
++        property ContextMenuRequest request
++        property bool isValidUrl: contextMenu.request && contextMenu.request.linkUrl != "" // not strict equality
++        property bool isAudio: contextMenu.request && contextMenu.request.mediaType === ContextMenuRequest.MediaTypeAudio
++        property bool isImage: contextMenu.request && contextMenu.request.mediaType === ContextMenuRequest.MediaTypeImage
++        property bool isVideo: contextMenu.request && contextMenu.request.mediaType === ContextMenuRequest.MediaTypeVideo
++        property real playbackRate: 100
++
++        onAboutToShow: {
++            if (webEngineView.settings.javascriptEnabled && (contextMenu.isAudio || contextMenu.isVideo)) {
++                const point = contextMenu.request.x + ', ' + contextMenu.request.y
++                const js = 'document.elementFromPoint(' + point + ').playbackRate * 100;'
++                webEngineView.runJavaScript(js, function(result) { contextMenu.playbackRate = result })
++            }
++        }
++
++        QQC2.MenuItem {
++            visible: contextMenu.isAudio || contextMenu.isVideo
++            height: visible ? implicitHeight : 0
++            text: contextMenu.request && contextMenu.request.mediaFlags & ContextMenuRequest.MediaPaused
++            ? i18nc("@action:inmenu", "Play")
++            : i18nc("@action:inmenu", "Pause")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.ToggleMediaPlayPause)
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.request && contextMenu.request.mediaFlags & ContextMenuRequest.MediaHasAudio
++            height: visible ? implicitHeight : 0
++            text:  contextMenu.request && contextMenu.request.mediaFlags & ContextMenuRequest.MediaMuted
++            ? i18nc("@action:inmenu", "Unmute")
++            : i18nc("@action:inmenu", "Mute")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.ToggleMediaMute)
++        }
++        QQC2.MenuItem {
++            visible: webEngineView.settings.javascriptEnabled && (contextMenu.isAudio || contextMenu.isVideo)
++            height: visible ? implicitHeight : 0
++            contentItem: RowLayout {
++                QQC2.Label {
++                    Layout.leftMargin: Kirigami.Units.largeSpacing
++                    Layout.fillWidth: true
++                    text: i18nc("@label", "Speed")
++                }
++                QQC2.SpinBox {
++                    Layout.rightMargin: Kirigami.Units.largeSpacing
++                    value: contextMenu.playbackRate
++                    from: 25
++                    to: 1000
++                    stepSize: 25
++                    onValueModified: {
++                        contextMenu.playbackRate = value
++                        const point = contextMenu.request.x + ', ' + contextMenu.request.y
++                        const js = 'document.elementFromPoint(' + point + ').playbackRate = ' + contextMenu.playbackRate / 100 + ';'
++                        webEngineView.runJavaScript(js)
++                    }
++                    textFromValue: function(value, locale) {
++                        return Number(value / 100).toLocaleString(locale, 'f', 2)
++                    }
++                }
++            }
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.isAudio || contextMenu.isVideo
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Loop")
++            checked: contextMenu.request && contextMenu.request.mediaFlags & ContextMenuRequest.MediaLoop
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.ToggleMediaLoop)
++        }
++        QQC2.MenuItem {
++            visible: webEngineView.settings.javascriptEnabled && contextMenu.isVideo
++            height: visible ? implicitHeight : 0
++            text: webEngineView.isFullScreen ? i18nc("@action:inmenu", "Exit Fullscreen") : i18nc("@action:inmenu", "Enter Fullscreen")
++            onTriggered: {
++                const point = contextMenu.request.x + ', ' + contextMenu.request.y
++                const js = webEngineView.isFullScreen
++                    ? 'document.exitFullscreen()'
++                    : 'document.elementFromPoint(' + point + ').requestFullscreen()'
++                webEngineView.runJavaScript(js)
++            }
++        }
++        QQC2.MenuItem {
++            visible: webEngineView.settings.javascriptEnabled && (contextMenu.isAudio || contextMenu.isVideo)
++            height: visible ? implicitHeight : 0
++            text: contextMenu.request && contextMenu.request.mediaFlags & ContextMenuRequest.MediaControls
++            ? i18nc("@action:inmenu", "Hide Controls")
++            : i18nc("@action:inmenu", "Show Controls")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.ToggleMediaControls)
++        }
++        QQC2.MenuSeparator { visible: contextMenu.isAudio || contextMenu.isVideo }
++        QQC2.MenuItem {
++            visible: (contextMenu.isAudio || contextMenu.isVideo) && contextMenu.request.mediaUrl !== currentWebView.url
++            height: visible ? implicitHeight : 0
++            text: webEngineView.isAppView
++                ? contextMenu.isVideo ? i18nc("@action:inmenu", "Open Video") : i18nc("@action:inmenu", "Open Audio")
++                : contextMenu.isVideo ? i18nc("@action:inmenu", "Open Video in New Tab") : i18nc("@action:inmenu", "Open Audio in New Tab")
++            onTriggered: {
++                Qt.openUrlExternally(contextMenu.request.mediaUrl);
++            }
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.isVideo
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Save Video")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.DownloadMediaToDisk)
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.isVideo
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Copy Video Link")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.CopyMediaUrlToClipboard)
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.isImage && contextMenu.request.mediaUrl !== currentWebView.url
++            height: visible ? implicitHeight : 0
++            text: webEngineView.isAppView ? i18nc("@action:inmenu", "Open Image") : i18nc("@action:inmenu", "Open Image in New Tab")
++            onTriggered: {
++                Qt.openUrlExternally(contextMenu.request.mediaUrl);
++            }
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.isImage
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Save Image")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.DownloadImageToDisk)
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.isImage
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Copy Image")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.CopyImageToClipboard)
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.isImage
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Copy Image Link")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.CopyImageUrlToClipboard)
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.request && contextMenu.isValidUrl
++            height: visible ? implicitHeight : 0
++            text: webEngineView.isAppView ? i18nc("@action:inmenu", "Open Link") : i18nc("@action:inmenu", "Open Link in New Window")
++            onTriggered: {
++                Qt.openUrlExternally(contextMenu.request.linkUrl);
++            }
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.request && contextMenu.isValidUrl
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Save Link")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.DownloadLinkToDisk)
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.request && contextMenu.isValidUrl
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Copy Link")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.CopyLinkToClipboard)
++        }
++        QQC2.MenuSeparator { visible: contextMenu.request && contextMenu.isValidUrl }
++        QQC2.MenuItem {
++            visible: contextMenu.request && (contextMenu.request.editFlags & ContextMenuRequest.CanCopy) && contextMenu.request.mediaUrl == ""
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Copy")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.Copy)
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.request && (contextMenu.request.editFlags & ContextMenuRequest.CanCut)
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Cut")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.Cut)
++        }
++        QQC2.MenuItem {
++            visible: contextMenu.request && (contextMenu.request.editFlags & ContextMenuRequest.CanPaste)
++            height: visible ? implicitHeight : 0
++            text: i18nc("@action:inmenu", "Paste")
++            onTriggered: webEngineView.triggerWebAction(WebEngineView.Paste)
++        }
++    }
++}
+diff --git a/webapp-viewer/useragent.cpp b/webapp-viewer/useragent.cpp
+new file mode 100644
+index 0000000000000000000000000000000000000000..3908499b4f4204d450d4bd2264e1252aae5614f4
+--- /dev/null
++++ b/webapp-viewer/useragent.cpp
+@@ -0,0 +1,59 @@
++// SPDX-FileCopyrightText: 2021 Jonah Brüchert <jbb at kaidan.im>
++// SPDX-FileCopyrightText: 2020 Rinigus <rinigus.git at gmail.com>
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#include "useragent.h"
++
++#include <QQuickWebEngineProfile>
++
++UserAgent *UserAgent::s_instance = nullptr;
++
++UserAgent::UserAgent(QObject *parent)
++    : QObject(parent)
++    , m_defaultProfile(QQuickWebEngineProfile::defaultProfile())
++    , m_defaultUserAgent(m_defaultProfile->httpUserAgent())
++    , m_chromeVersion(extractValueFromAgent(u"Chrome"))
++    , m_appleWebKitVersion(extractValueFromAgent(u"AppleWebKit"))
++    , m_webEngineVersion(extractValueFromAgent(u"QtWebEngine"))
++    , m_safariVersion(extractValueFromAgent(u"Safari"))
++{
++}
++
++QString UserAgent::userAgent() const
++{
++    if (m_userAgent.isEmpty()) {
++        return QStringView(
++                   u"Mozilla/5.0 (%1) AppleWebKit/%2 (KHTML, like Gecko) QtWebEngine/%3 "
++                   u"Chrome/%4 %5 Safari/%6")
++            .arg(u"X11; Linux x86_64", m_appleWebKitVersion, m_webEngineVersion, m_chromeVersion, u"Desktop", m_safariVersion);
++    }
++
++    return m_userAgent;
++}
++
++void UserAgent::setUserAgent(QString userAgent)
++{
++    m_userAgent = userAgent;
++    Q_EMIT userAgentChanged();
++}
++
++QStringView UserAgent::extractValueFromAgent(const QStringView key)
++{
++    const int index = m_defaultUserAgent.indexOf(key) + key.length() + 1;
++    int endIndex = m_defaultUserAgent.indexOf(u' ', index);
++    if (endIndex == -1) {
++        endIndex = m_defaultUserAgent.size();
++    }
++    return QStringView(m_defaultUserAgent).mid(index, endIndex - index);
++}
++
++UserAgent *UserAgent::instance()
++{
++    if (!s_instance)
++        s_instance = new UserAgent();
++
++    return s_instance;
++}
++
++#include "moc_useragent.cpp"
+diff --git a/webapp-viewer/useragent.h b/webapp-viewer/useragent.h
+new file mode 100644
+index 0000000000000000000000000000000000000000..6fe88ea07bc2182def8b44d09b3a9d3993347cf8
+--- /dev/null
++++ b/webapp-viewer/useragent.h
+@@ -0,0 +1,47 @@
++// SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb at kaidan.im>
++// SPDX-FileCopyrightText: 2020 Rinigus <rinigus.git at gmail.com>
++// SPDX-FileCopyrightText: 2025 Devin Lin <devin at kde.org>
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#pragma once
++
++#include <QJSEngine>
++#include <QObject>
++#include <QQmlEngine>
++#include <QtQml/qqmlregistration.h>
++
++class QQuickWebEngineProfile;
++
++class UserAgent : public QObject
++{
++    Q_OBJECT
++    Q_PROPERTY(QString userAgent READ userAgent WRITE setUserAgent NOTIFY userAgentChanged)
++    QML_ELEMENT
++    QML_SINGLETON
++
++public:
++    static UserAgent *instance();
++    static UserAgent *create(QQmlEngine *, QJSEngine *)
++    {
++        return UserAgent::instance();
++    }
++    explicit UserAgent(QObject *parent = nullptr);
++    QString userAgent() const;
++    void setUserAgent(QString userAgent);
++
++Q_SIGNALS:
++    void userAgentChanged();
++
++private:
++    QStringView extractValueFromAgent(const QStringView key);
++
++    const QQuickWebEngineProfile *m_defaultProfile;
++    const QString m_defaultUserAgent;
++    const QStringView m_chromeVersion;
++    const QStringView m_appleWebKitVersion;
++    const QStringView m_webEngineVersion;
++    const QStringView m_safariVersion;
++
++    QString m_userAgent;
++    static UserAgent *s_instance;
++};
+diff --git a/webapp-viewer/webprofile.cpp b/webapp-viewer/webprofile.cpp
+new file mode 100644
+index 0000000000000000000000000000000000000000..d44a658ea0701b25c56bfb78b3054ec1fac69fe2
+--- /dev/null
++++ b/webapp-viewer/webprofile.cpp
+@@ -0,0 +1,66 @@
++// SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb at kaidan.im>
++//
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#include "webprofile.h"
++
++#include <KLocalizedString>
++#include <QGuiApplication>
++#include <QQuickItem>
++#include <QQuickWindow>
++#include <QWebEngineNotification>
++
++#include <KNotification>
++
++class QQuickWebEngineDownloadRequest : public DownloadItem
++{
++};
++
++WebProfile::WebProfile(QObject *parent)
++    : QQuickWebEngineProfile(parent)
++    , m_urlInterceptor(nullptr)
++{
++    connect(this, &QQuickWebEngineProfile::downloadRequested, this, &WebProfile::handleDownload);
++    connect(this, &QQuickWebEngineProfile::downloadFinished, this, &WebProfile::handleDownloadFinished);
++    connect(this, &QQuickWebEngineProfile::presentNotification, this, &WebProfile::showNotification);
++}
++
++void WebProfile::handleDownload(QQuickWebEngineDownloadRequest *downloadItem)
++{
++    // TODO: do we handle downloads?
++}
++
++void WebProfile::handleDownloadFinished(DownloadItem *downloadItem)
++{
++    // TODO: do we handle downloads?
++}
++
++void WebProfile::showNotification(QWebEngineNotification *webNotification)
++{
++    auto *notification = new KNotification(QStringLiteral("web-notification"));
++    notification->setComponentName(QStringLiteral("plasma-bigscreen"));
++    notification->setTitle(webNotification->title());
++    notification->setText(webNotification->message());
++    notification->setPixmap(QPixmap::fromImage(webNotification->icon()));
++
++    connect(notification, &KNotification::closed, webNotification, &QWebEngineNotification::close);
++
++    auto defaultAction = notification->addDefaultAction(i18n("Open"));
++    connect(defaultAction, &KNotificationAction::activated, webNotification, &QWebEngineNotification::click);
++
++    notification->sendEvent();
++}
++
++QWebEngineUrlRequestInterceptor *WebProfile::urlInterceptor() const
++{
++    return m_urlInterceptor;
++}
++
++void WebProfile::setUrlInterceptor(QWebEngineUrlRequestInterceptor *urlRequestInterceptor)
++{
++    setUrlRequestInterceptor(urlRequestInterceptor);
++    m_urlInterceptor = urlRequestInterceptor;
++    Q_EMIT urlInterceptorChanged();
++}
++
++#include "moc_webprofile.cpp"
+diff --git a/webapp-viewer/webprofile.h b/webapp-viewer/webprofile.h
+new file mode 100644
+index 0000000000000000000000000000000000000000..066d277f82e2d1e299e915e93d5ce02a95a85c28
+--- /dev/null
++++ b/webapp-viewer/webprofile.h
+@@ -0,0 +1,44 @@
++// SPDX-FileCopyrightText: 2020 Jonah Brüchert <jbb at kaidan.im>
++//
++// SPDX-License-Identifier: GPL-2.0-or-later
++
++#pragma once
++
++#include <QObject>
++#include <QQuickItem>
++#include <QQuickWebEngineProfile>
++#include <QWebEngineDownloadRequest>
++#include <QWebEngineUrlRequestInterceptor>
++#include <QtQml/qqmlregistration.h>
++
++using DownloadItem = QWebEngineDownloadRequest;
++
++class QWebEngineNotification;
++class QQuickItem;
++class QWebEngineUrlRequestInterceptor;
++
++class WebProfile : public QQuickWebEngineProfile
++{
++    Q_OBJECT
++
++    Q_PROPERTY(QWebEngineUrlRequestInterceptor *urlInterceptor WRITE setUrlInterceptor READ urlInterceptor NOTIFY urlInterceptorChanged)
++
++    QML_ELEMENT
++
++public:
++    explicit WebProfile(QObject *parent = nullptr);
++
++    Q_SIGNAL void urlInterceptorChanged();
++
++    QWebEngineUrlRequestInterceptor *urlInterceptor() const;
++    void setUrlInterceptor(QWebEngineUrlRequestInterceptor *urlRequestInterceptor);
++
++private:
++    void handleDownload(QQuickWebEngineDownloadRequest *downloadItem);
++    void handleDownloadFinished(DownloadItem *downloadItem);
++    void showNotification(QWebEngineNotification *webNotification);
++
++    // A valid property needs a read function, and there is no getter in QQuickWebEngineProfile
++    // so store a pointer ourselves
++    QWebEngineUrlRequestInterceptor *m_urlInterceptor;
++};
\ No newline at end of file


More information about the Neon-commits mailing list