[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