Roles/kde: add kwin and plasma-workspace patches

This commit is contained in:
Toast 2025-01-17 22:21:12 +01:00
parent b6bd1ec321
commit 77edacc9d4
4 changed files with 958 additions and 0 deletions

View file

@ -0,0 +1,41 @@
From 45a5d8844b36404334301f5da6e75f1a345e0c80 Mon Sep 17 00:00:00 2001
From: Xaver Hugl <xaver.hugl@gmail.com>
Date: Fri, 10 Jan 2025 13:45:30 +0000
Subject: [PATCH] plugins/screencast: call ItemRenderer::begin/endFrame
The OpenGL renderer references the explicit sync release points for client buffers
during rendering, and releases them in endFrame. If endFrame never gets called though
(for example because we're doing direct scanout) then the release points never get
signaled, and the client very quickly runs out of buffers to use and freezes.
BUG: 495287
(cherry picked from commit b1031ea63eaa8c9bf5c70157d1b6bf8eb0f5a74a)
Co-authored-by: Xaver Hugl <xaver.hugl@gmail.com>
---
src/plugins/screencast/windowscreencastsource.cpp | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/plugins/screencast/windowscreencastsource.cpp b/src/plugins/screencast/windowscreencastsource.cpp
index 24ef92aad7b..b396eed46f9 100644
--- a/src/plugins/screencast/windowscreencastsource.cpp
+++ b/src/plugins/screencast/windowscreencastsource.cpp
@@ -75,11 +75,11 @@ void WindowScreenCastSource::render(GLFramebuffer *target)
RenderTarget renderTarget(target);
RenderViewport viewport(m_window->clientGeometry(), 1, renderTarget);
- GLFramebuffer::pushFramebuffer(target);
+ Compositor::self()->scene()->renderer()->beginFrame(renderTarget, viewport);
glClearColor(0.0, 0.0, 0.0, 0.0);
glClear(GL_COLOR_BUFFER_BIT);
Compositor::self()->scene()->renderer()->renderItem(renderTarget, viewport, m_window->windowItem(), Scene::PAINT_WINDOW_TRANSFORMED, infiniteRegion(), WindowPaintData{});
- GLFramebuffer::popFramebuffer();
+ Compositor::self()->scene()->renderer()->endFrame();
}
std::chrono::nanoseconds WindowScreenCastSource::clock() const
--
GitLab

View file

@ -0,0 +1,915 @@
diff --git a/appiumtests/applets/CMakeLists.txt b/appiumtests/applets/CMakeLists.txt
index 19229541aa..6715dd1f8f 100644
--- a/appiumtests/applets/CMakeLists.txt
+++ b/appiumtests/applets/CMakeLists.txt
@@ -59,7 +59,7 @@ add_test(
NAME notificationstest
COMMAND selenium-webdriver-at-spi-run ${CMAKE_CURRENT_SOURCE_DIR}/notificationstest.py --failfast
)
-set_tests_properties(notificationstest PROPERTIES TIMEOUT 120)
+set_tests_properties(notificationstest PROPERTIES TIMEOUT 120 ENVIRONMENT "KACTIVITYMANAGERD_PATH=${KDE_INSTALL_FULL_LIBEXECDIR}/kactivitymanagerd;USE_CUSTOM_BUS=1")
add_test(
NAME digitalclocktest
diff --git a/appiumtests/applets/kicker/favoritetest.py b/appiumtests/applets/kicker/favoritetest.py
new file mode 100755
index 0000000000..50613c3db1
--- /dev/null
+++ b/appiumtests/applets/kicker/favoritetest.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+
+# SPDX-License-Identifier: BSD-3-Clause
+# SPDX-FileCopyrightText: 2022-2023 Harald Sitter <sitter@kde.org>
+
+import logging
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+from typing import Final
+
+from gi.repository import Gio, GLib
+
+sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir, "utils"))
+from GLibMainLoopThread import GLibMainLoopThread
+
+KDE_VERSION: Final = 6
+KACTIVITYMANAGERD_SERVICE_NAME: Final = "org.kde.ActivityManager"
+KACTIVITYMANAGERD_PATH: Final = os.getenv("KACTIVITYMANAGERD_PATH", "/usr/libexec/kactivitymanagerd")
+QMLTEST_EXEC: Final = os.getenv("QMLTEST_EXEC", "/usr/bin/qmltestrunner6")
+
+
+def name_has_owner(session_bus: Gio.DBusConnection, name: str) -> bool:
+ """
+ Whether the given name is available on session bus
+ """
+ message: Gio.DBusMessage = Gio.DBusMessage.new_method_call("org.freedesktop.DBus", "/", "org.freedesktop.DBus", "NameHasOwner")
+ message.set_body(GLib.Variant("(s)", [name]))
+ reply, _ = session_bus.send_message_with_reply_sync(message, Gio.DBusSendMessageFlags.NONE, 1000)
+ return reply and reply.get_signature() == 'b' and reply.get_body().get_child_value(0).get_boolean()
+
+
+def build_ksycoca() -> None:
+ subprocess.check_call([f"kbuildsycoca{KDE_VERSION}"], stdout=sys.stderr, stderr=sys.stderr, env=os.environ)
+
+
+def start_kactivitymanagerd() -> subprocess.Popen:
+ session_bus: Gio.DBusConnection = Gio.bus_get_sync(Gio.BusType.SESSION)
+ assert not name_has_owner(session_bus, KACTIVITYMANAGERD_SERVICE_NAME)
+
+ os.makedirs(os.path.join(GLib.get_user_config_dir(), "menus"))
+ shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "applications.menu"), os.path.join(GLib.get_user_config_dir(), "menus"))
+
+ kactivitymanagerd = subprocess.Popen([KACTIVITYMANAGERD_PATH], stdout=sys.stderr, stderr=sys.stderr, env=os.environ)
+ kactivitymanagerd_started: bool = False
+ for _ in range(10):
+ if name_has_owner(session_bus, KACTIVITYMANAGERD_SERVICE_NAME):
+ kactivitymanagerd_started = True
+ break
+ logging.info("waiting for kactivitymanagerd to appear on the DBus session")
+ time.sleep(1)
+ assert kactivitymanagerd_started
+
+ build_ksycoca()
+
+ return kactivitymanagerd
+
+
+class TestDBusInterface:
+ """
+ D-Bus interface for org.kde.kickertest
+ """
+
+ BUS_NAME: Final = "org.kde.kickertest"
+ OBJECT_PATH: Final = "/test"
+ INTERFACE_NAME: Final = "org.kde.kickertest"
+
+ connection: Gio.DBusConnection
+
+ def __init__(self) -> None:
+ self.reg_id: int = 0
+ self.owner_id: int = Gio.bus_own_name(Gio.BusType.SESSION, self.BUS_NAME, Gio.BusNameOwnerFlags.NONE, self.on_bus_acquired, None, None)
+ assert self.owner_id > 0
+
+ def on_bus_acquired(self, connection: Gio.DBusConnection, name: str, *args) -> None:
+ """
+ The interface is ready, now register objects.
+ """
+ self.connection = connection
+ introspection_data = Gio.DBusNodeInfo.new_for_xml("""
+<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
+ "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
+<node>
+ <interface name="org.kde.kickertest">
+ <method name="DeleteAndRebuildDatabase1"/>
+ <method name="DeleteAndRebuildDatabase2"/>
+ </interface>
+</node>
+""")
+ self.reg_id = connection.register_object(self.OBJECT_PATH, introspection_data.interfaces[0], self.handle_method_call, None, None)
+ assert self.reg_id > 0
+ logging.info("interface registered")
+
+ def handle_method_call(self, connection: Gio.DBusConnection, sender: str, object_path: str, interface_name: str, method_name: str, parameters: GLib.Variant, invocation: Gio.DBusMethodInvocation) -> None:
+ logging.info("method call %s", method_name)
+
+ if method_name == "DeleteAndRebuildDatabase1":
+ os.remove(KickerTest.desktop_entry_1)
+ build_ksycoca()
+ invocation.return_value(None)
+ elif method_name == "DeleteAndRebuildDatabase2":
+ os.remove(KickerTest.desktop_entry_2)
+ build_ksycoca()
+ invocation.return_value(None)
+
+
+class KickerTest(unittest.TestCase):
+ kactivitymanagerd: subprocess.Popen
+ loop_thread: GLibMainLoopThread
+ dbus_interface: TestDBusInterface
+
+ temp_dir: tempfile.TemporaryDirectory
+ desktop_entry_1: str
+ desktop_entry_2: str
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ # Prepare desktop files
+ # 1
+ os.makedirs(os.path.join(GLib.get_user_data_dir(), "applications"))
+ shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "kickertest.desktop"), os.path.join(GLib.get_user_data_dir(), "applications"))
+ cls.desktop_entry_1 = os.path.join(GLib.get_user_data_dir(), "applications", "kickertest.desktop")
+ # 2
+ cls.temp_dir = tempfile.TemporaryDirectory()
+ os.makedirs(os.path.join(cls.temp_dir.name, "applications"))
+ shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "kickertest.desktop"), os.path.join(cls.temp_dir.name, "applications"))
+ cls.desktop_entry_2 = os.path.join(cls.temp_dir.name, "applications", "kickertest.desktop")
+
+ os.environ["LC_ALL"] = "en_US.UTF-8"
+ os.environ["QT_LOGGING_RULES"] = "org.kde.plasma.kicker.debug=true;kf.coreaddons.kdirwatch.debug=true"
+ os.environ["XDG_DATA_DIRS"] = os.environ["XDG_DATA_DIRS"] + ":" + cls.temp_dir.name
+
+ cls.kactivitymanagerd = start_kactivitymanagerd()
+
+ cls.loop_thread = GLibMainLoopThread()
+ cls.loop_thread.start()
+ cls.dbus_interface = TestDBusInterface()
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ cls.loop_thread.quit()
+ cls.kactivitymanagerd.kill()
+ cls.kactivitymanagerd.wait(10)
+
+ def test_qml(self) -> None:
+ """
+ 1. Add an entry to Favorites
+ 2. Remove the entry from Favorites
+ 3. Hide invalid entries automatically and don't crash when there are multiple entries with the same desktop name
+ """
+ with subprocess.Popen([QMLTEST_EXEC, "-input", os.path.join(os.path.dirname(os.path.abspath(__file__)), 'favoritetest.qml')], stdout=sys.stderr, stderr=sys.stderr) as process:
+ self.assertEqual(process.wait(60), 0)
+
+
+if __name__ == '__main__':
+ assert "USE_CUSTOM_BUS" in os.environ
+ logging.getLogger().setLevel(logging.INFO)
+ unittest.main()
diff --git a/appiumtests/applets/notificationstest.py b/appiumtests/applets/notificationstest.py
index 8917121a94..cd34a32905 100755
--- a/appiumtests/applets/notificationstest.py
+++ b/appiumtests/applets/notificationstest.py
@@ -5,6 +5,7 @@
import base64
import os
+import shutil
import subprocess
import tempfile
import time
@@ -15,6 +16,7 @@ import gi
from appium import webdriver
from appium.options.common.base import AppiumOptions
from appium.webdriver.common.appiumby import AppiumBy
+from selenium.common.exceptions import (NoSuchElementException, WebDriverException)
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
@@ -22,6 +24,8 @@ gi.require_version('Gdk', '4.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import Gdk, GdkPixbuf, Gio, GLib
+from kicker.favoritetest import start_kactivitymanagerd
+
WIDGET_ID: Final = "org.kde.plasma.notifications"
KDE_VERSION: Final = 6
@@ -48,17 +52,25 @@ class NotificationsTest(unittest.TestCase):
"""
driver: webdriver.Remote
+ kactivitymanagerd: subprocess.Popen
@classmethod
def setUpClass(cls) -> None:
"""
Opens the widget and initialize the webdriver
"""
+ # Make history work
+ cls.kactivitymanagerd = start_kactivitymanagerd()
+
+ os.makedirs(os.path.join(GLib.get_user_data_dir(), "knotifications6"))
+ shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir, "libnotificationmanager", "libnotificationmanager.notifyrc"), os.path.join(GLib.get_user_data_dir(), "knotifications6"))
+
options = AppiumOptions()
options.set_capability("app", f"plasmawindowed -p org.kde.plasma.nano {WIDGET_ID}")
options.set_capability("timeouts", {'implicit': 10000})
options.set_capability("environ", {
"LC_ALL": "en_US.UTF-8",
+ "QT_LOGGING_RULES": "kf.notification*.debug=true;org.kde.plasma.notificationmanager.debug=true",
})
cls.driver = webdriver.Remote(command_executor='http://127.0.0.1:4723', options=options)
@@ -75,6 +87,8 @@ class NotificationsTest(unittest.TestCase):
Make sure to terminate the driver again, lest it dangles.
"""
subprocess.check_call([f"kquitapp{KDE_VERSION}", "plasmawindowed"])
+ cls.kactivitymanagerd.kill()
+ cls.kactivitymanagerd.wait(10)
for _ in range(10):
try:
subprocess.check_call(["pidof", "plasmawindowed"])
@@ -83,6 +97,15 @@ class NotificationsTest(unittest.TestCase):
time.sleep(1)
cls.driver.quit()
+ def close_notifications(self) -> None:
+ wait = WebDriverWait(self.driver, 5)
+ for button in self.driver.find_elements(AppiumBy.XPATH, "//button[@name='Close']"):
+ try:
+ button.click()
+ wait.until_not(lambda _: button.is_displayed())
+ except WebDriverException:
+ pass
+
def test_0_open(self) -> None:
"""
Tests the widget can be opened
@@ -106,6 +129,10 @@ class NotificationsTest(unittest.TestCase):
wait = WebDriverWait(self.driver, 5)
wait.until(EC.presence_of_element_located((AppiumBy.NAME, summary)))
+<<<<<<< HEAD
+=======
+ self.close_notifications()
+>>>>>>> 5145d877d8 (applets/notifications: suppress inhibited notifications after "Do not disturb" is off)
def take_screenshot(self) -> str:
with tempfile.TemporaryDirectory() as temp_dir:
@@ -123,6 +150,7 @@ class NotificationsTest(unittest.TestCase):
partial_pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 16, 16)
colors = (0xff0000ff, 0x00ff00ff, 0x0000ffff)
for color in colors:
+ logging.info(f"Testing color: {color}")
pixbuf.fill(color)
send_notification({
"app_name": "Appium Test",
@@ -145,7 +173,29 @@ class NotificationsTest(unittest.TestCase):
wait.until(EC.presence_of_element_located((AppiumBy.NAME, summary + str(color))))
partial_pixbuf.fill(color)
partial_image = base64.b64encode(Gdk.Texture.new_for_pixbuf(partial_pixbuf).save_to_png_bytes().get_data()).decode()
+<<<<<<< HEAD
self.driver.find_image_occurrence(self.take_screenshot(), partial_image)
+=======
+ try:
+ self.driver.find_image_occurrence(self.take_screenshot(), partial_image)
+ except WebDriverException: # Popup animation
+ self.driver.find_image_occurrence(self.take_screenshot(), partial_image)
+ self.close_notifications()
+
+ def test_2_notification_with_explicit_timeout(self) -> None:
+ """
+ Sends notifications with expire_timeout
+ """
+ summary = "expire_timeout"
+ send_notification({
+ "app_name": "Appium Test",
+ "summary": summary,
+ "body": "Will it disappear automatically?",
+ "timeout": 2000,
+ })
+ element = self.driver.find_element(AppiumBy.NAME, summary)
+ WebDriverWait(self.driver, 5).until_not(lambda _: element.is_displayed())
+>>>>>>> 5145d877d8 (applets/notifications: suppress inhibited notifications after "Do not disturb" is off)
def test_3_accessible_description_html_to_plaintext(self) -> None:
"""
@@ -157,6 +207,202 @@ class NotificationsTest(unittest.TestCase):
})
wait = WebDriverWait(self.driver, 5)
wait.until(EC.presence_of_element_located(("description", "biublinkwww.example.org from Appium Test")))
+<<<<<<< HEAD
+=======
+ self.close_notifications()
+
+ def test_4_actions(self) -> None:
+ """
+ When the "actions" key is set, a notification can provide actions.
+ """
+ loop = GLib.MainLoop()
+ activation_token = False
+ params_1: list[Any] = []
+ action_invoked = False
+ params_2: list[Any] = []
+ notification_closed = False
+ params_3: list[Any] = []
+
+ def notification_signal_handler(d_bus_proxy: Gio.DBusProxy, sender_name: str, signal_name: str, parameters: GLib.Variant) -> None:
+ nonlocal params_2, params_3, params_1, activation_token, action_invoked, notification_closed
+ logging.info(f"received signal {signal_name}")
+ match signal_name:
+ case "ActivationToken":
+ params_1 = parameters.unpack()
+ activation_token = True
+ case "ActionInvoked":
+ params_2 = parameters.unpack()
+ action_invoked = True
+ case "NotificationClosed":
+ params_3 = parameters.unpack()
+ notification_closed = True
+ loop.quit()
+
+ connection_id = self.notification_proxy.connect("g-signal", notification_signal_handler)
+ self.addCleanup(lambda: self.notification_proxy.disconnect(connection_id))
+
+ notification_id = send_notification({
+ "app_name": "Appium Test",
+ "body": "A notification with actions",
+ "actions": ["action1", "FooAction", "action2", "BarAction"],
+ })
+ self.driver.find_element(AppiumBy.NAME, "BarAction")
+ element = self.driver.find_element(AppiumBy.NAME, "FooAction")
+ element.click()
+ loop.run()
+ self.assertTrue(activation_token)
+ self.assertEqual(params_1[0], notification_id)
+ self.assertTrue(action_invoked)
+ self.assertEqual(params_2[0], notification_id)
+ self.assertEqual(params_2[1], "action1")
+ self.assertTrue(notification_closed)
+ self.assertEqual(params_3[0], notification_id)
+ self.assertEqual(params_3[1], 3) # reason: Revoked
+ self.assertFalse(element.is_displayed())
+
+ def test_5_inline_reply(self) -> None:
+ """
+ When the action list has "inline-reply", the notification popup will contain a text field and a reply button.
+ """
+ loop = GLib.MainLoop()
+ notification_replied = False
+ params: list[Any] = [] # id, text
+
+ def notification_signal_handler(d_bus_proxy: Gio.DBusProxy, sender_name: str, signal_name: str, parameters: GLib.Variant) -> None:
+ nonlocal params, notification_replied
+ logging.info(f"received signal {signal_name}")
+ if signal_name == "NotificationReplied":
+ params = parameters.unpack()
+ notification_replied = True
+ loop.quit()
+
+ connection_id = self.notification_proxy.connect("g-signal", notification_signal_handler)
+ self.addCleanup(lambda: self.notification_proxy.disconnect(connection_id))
+
+ # When there is only one action and it is a reply action, show text field right away
+ notification_id = send_notification({
+ "app_name": "Appium Test",
+ "body": "A notification with actions 1",
+ "actions": ["inline-reply", ""], # Use the default label
+ })
+ reply_text = "this is a reply"
+ self.driver.find_element(AppiumBy.NAME, "begin reply").click()
+ self.driver.find_element(AppiumBy.NAME, "Type a reply…").send_keys(reply_text)
+ element = self.driver.find_element(AppiumBy.NAME, "Send")
+ element.click()
+ loop.run()
+ self.assertTrue(notification_replied)
+ self.assertEqual(params[0], notification_id)
+ self.assertEqual(params[1], reply_text)
+ self.assertFalse(element.is_displayed())
+
+ notification_replied = False
+ notification_id = send_notification({
+ "app_name": "Appium Test",
+ "body": "A notification with actions 2",
+ "actions": ["inline-reply", ""],
+ "hints": {
+ "x-kde-reply-submit-button-text": GLib.Variant("s", "Reeply"), # Use a custom label
+ "x-kde-reply-placeholder-text": GLib.Variant("s", "A placeholder"), # Use a custom placeholder
+ },
+ })
+ reply_text = "this is another reply"
+ self.driver.find_element(AppiumBy.NAME, "begin reply").click()
+ self.driver.find_element(AppiumBy.NAME, "A placeholder").send_keys(reply_text)
+ element = self.driver.find_element(AppiumBy.NAME, "Reeply")
+ element.click()
+ loop.run()
+ self.assertTrue(notification_replied)
+ self.assertEqual(params[0], notification_id)
+ self.assertEqual(params[1], reply_text)
+ self.assertFalse(element.is_displayed())
+
+ notification_replied = False
+ notification_id = send_notification({
+ "app_name": "Appium Test",
+ "body": "A notification with actions 3",
+ "actions": ["inline-reply", "Replyy", "foo", "Foo", "bar", "Bar"], # Click to show the text field
+ })
+ self.driver.find_element(AppiumBy.NAME, "Foo")
+ self.driver.find_element(AppiumBy.NAME, "Bar")
+ element = self.driver.find_element(AppiumBy.NAME, "Replyy")
+ element.click()
+ reply_text = "Click Replyy to reply"
+ self.driver.find_element(AppiumBy.NAME, "Type a reply…").send_keys(reply_text)
+ self.assertFalse(element.is_displayed())
+ element = self.driver.find_element(AppiumBy.NAME, "Send")
+ element.click()
+ loop.run()
+ self.assertTrue(notification_replied)
+ self.assertEqual(params[0], notification_id)
+ self.assertEqual(params[1], reply_text)
+
+ def test_6_thumbnail(self) -> None:
+ """
+ When a notification has "x-kde-urls" hint, a thumbnail will be shown for the first url in the list
+ """
+ with tempfile.TemporaryDirectory() as temp_dir:
+ pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 256, 256)
+ colors = (0xff0000ff, 0x00ff00ff, 0x0000ffff)
+ for color in colors:
+ pixbuf.fill(color)
+ pixbuf.savev(os.path.join(temp_dir, f"{str(color)}.png"), "png")
+
+ url_list = [f"file://{os.path.join(temp_dir, path)}" for path in os.listdir(temp_dir)]
+ url_list.sort()
+ send_notification({
+ "app_name": "Appium Test",
+ "body": "Thumbnail",
+ "hints": {
+ "x-kde-urls": GLib.Variant("as", url_list),
+ },
+ "timeout": 10 * 1000,
+ })
+
+ self.driver.find_element(AppiumBy.NAME, "More Options…")
+
+ partial_pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 100, 100)
+ partial_pixbuf.fill(colors[1]) # Green is the first item
+ partial_image = base64.b64encode(Gdk.Texture.new_for_pixbuf(partial_pixbuf).save_to_png_bytes().get_data()).decode()
+
+ def match_image(driver) -> bool:
+ try:
+ self.driver.find_image_occurrence(self.take_screenshot(), partial_image)
+ return True
+ except WebDriverException:
+ return False
+
+ WebDriverWait(self.driver, 10).until(match_image)
+ self.close_notifications()
+
+ def test_7_do_not_disturb(self) -> None:
+ """
+ Suppress inhibited notifications after "Do not disturb" is turned off, and show a summary for unread inhibited notifications.
+ """
+ self.driver.find_element(AppiumBy.NAME, "Do not disturb").click()
+ dnd_button = self.driver.find_element(AppiumBy.XPATH, "//*[@name='Do not disturb' and contains(@states, 'checked')]")
+
+ summary = "Do not disturb me"
+ for i in range(2):
+ send_notification({
+ "app_name": "Appium Test",
+ "summary": summary + str(i),
+ "hints": {
+ "desktop-entry": GLib.Variant("s", "org.kde.plasmashell"),
+ },
+ "timeout": 60 * 1000,
+ })
+ title = self.driver.find_element(AppiumBy.XPATH, f"//heading[starts-with(@name, '{summary}') and contains(@accessibility-id, 'FullRepresentation')]")
+ self.assertRaises(NoSuchElementException, self.driver.find_element, AppiumBy.XPATH, f"//notification[starts-with(@name, '{summary}')]")
+
+ dnd_button.click()
+ self.driver.find_element(AppiumBy.XPATH, "//notification[@name='Unread Notifications' and @description='2 notifications were received while Do Not Disturb was active. from Notification Manager']")
+ self.driver.find_element(AppiumBy.XPATH, "//button[@name='Close' and contains(@accessibility-id, 'NotificationPopup')]").click()
+
+ # Notifications can only be cleared after they are expired, otherwise they will stay in the list
+ self.driver.find_element(AppiumBy.NAME, "Clear All Notifications").click()
+ WebDriverWait(self.driver, 5).until_not(lambda _: title.is_displayed())
+>>>>>>> 5145d877d8 (applets/notifications: suppress inhibited notifications after "Do not disturb" is off)
if __name__ == '__main__':
diff --git a/applets/notifications/package/contents/ui/FullRepresentation.qml b/applets/notifications/package/contents/ui/FullRepresentation.qml
index f949dad46a..33838599f6 100644
--- a/applets/notifications/package/contents/ui/FullRepresentation.qml
+++ b/applets/notifications/package/contents/ui/FullRepresentation.qml
@@ -71,6 +71,14 @@ PlasmaExtras.Representation {
checkable: true
checked: Globals.inhibited
+ Accessible.onPressAction: if (Globals.inhibited) {
+ Globals.revokeInhibitions();
+ } else {
+ let date = new Date();
+ date.setFullYear(date.getFullYear() + 1);
+ notificationSettings.notificationsInhibitedUntil = date;
+ notificationSettings.save();
+ }
KeyNavigation.down: list
KeyNavigation.tab: list
diff --git a/applets/notifications/package/contents/ui/global/Globals.qml b/applets/notifications/package/contents/ui/global/Globals.qml
index c46c32921a..2238653eff 100644
--- a/applets/notifications/package/contents/ui/global/Globals.qml
+++ b/applets/notifications/package/contents/ui/global/Globals.qml
@@ -34,6 +34,10 @@ QtObject {
property bool inhibited: false
onInhibitedChanged: {
+ if (!inhibited) {
+ popupNotificationsModel.showInhibitionSummary();
+ }
+
var pa = pulseAudio.item;
if (!pa) {
return;
@@ -405,6 +409,7 @@ QtObject {
limit: plasmoid ? (Math.ceil(globals.screenRect.height / (Kirigami.Units.gridUnit * 4))) : 0
showExpired: false
showDismissed: false
+ showAddedDuringInhibition: false
blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications
blacklistedNotifyRcNames: notificationSettings.popupBlacklistedServices
whitelistedDesktopEntries: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedApplications : []
@@ -613,9 +618,13 @@ QtObject {
onKillJobClicked: popupNotificationsModel.killJob(popupNotificationsModel.index(index, 0))
// popup width is fixed
- onHeightChanged: positionPopups()
+ onHeightChanged: globals.positionPopups()
Component.onCompleted: {
+ if (globals.inhibited) {
+ model.wasAddedDuringInhibition = false; // Don't count already shown notifications
+ }
+
if (model.type === NotificationManager.Notifications.NotificationType && model.desktopEntry) {
// Register apps that were seen spawning a popup so they can be configured later
// Apps with notifyrc can already be configured anyway
diff --git a/libnotificationmanager/CMakeLists.txt b/libnotificationmanager/CMakeLists.txt
index d04d8a4a24..5c48653ede 100644
--- a/libnotificationmanager/CMakeLists.txt
+++ b/libnotificationmanager/CMakeLists.txt
@@ -84,6 +84,7 @@ target_link_libraries(notificationmanager
KF6::I18n
KF6::WindowSystem
KF6::ItemModels # KDescendantsProxyModel
+ KF6::Notifications # Inhibition summary
KF6::KIOFileWidgets
Plasma::Plasma
KF6::Screen
@@ -135,3 +136,6 @@ install(EXPORT notificationmanagerLibraryTargets
install(FILES plasmanotifyrc
DESTINATION ${KDE_INSTALL_CONFDIR})
+
+install(FILES libnotificationmanager.notifyrc
+ DESTINATION ${KDE_INSTALL_KNOTIFYRCDIR})
diff --git a/libnotificationmanager/abstractnotificationsmodel.cpp b/libnotificationmanager/abstractnotificationsmodel.cpp
index 8bd55bc26c..8307a1e8f7 100644
--- a/libnotificationmanager/abstractnotificationsmodel.cpp
+++ b/libnotificationmanager/abstractnotificationsmodel.cpp
@@ -104,6 +104,7 @@ void AbstractNotificationsModel::Private::onNotificationReplaced(uint replacedId
newNotification.setExpired(oldNotification.expired());
newNotification.setDismissed(oldNotification.dismissed());
newNotification.setRead(oldNotification.read());
+ newNotification.setWasAddedDuringInhibition(Server::self().inhibited());
notifications[row] = newNotification;
const QModelIndex idx = q->index(row, 0);
@@ -378,6 +379,9 @@ QVariant AbstractNotificationsModel::data(const QModelIndex &index, int role) co
case Notifications::TransientRole:
return notification.transient();
+ case Notifications::WasAddedDuringInhibitionRole:
+ return notification.wasAddedDuringInhibition();
+
case Notifications::HasReplyActionRole:
return notification.hasReplyAction();
case Notifications::ReplyActionLabelRole:
@@ -416,6 +420,12 @@ bool AbstractNotificationsModel::setData(const QModelIndex &index, const QVarian
dirty = true;
}
break;
+ case Notifications::WasAddedDuringInhibitionRole:
+ if (bool v = value.toBool(); v != notification.wasAddedDuringInhibition()) {
+ notification.setWasAddedDuringInhibition(v);
+ dirty = true;
+ }
+ break;
}
if (dirty) {
@@ -471,7 +481,7 @@ void AbstractNotificationsModel::clear(Notifications::ClearFlags flags)
for (int i = 0; i < d->notifications.count(); ++i) {
const Notification &notification = d->notifications.at(i);
- if (flags.testFlag(Notifications::ClearExpired) && notification.expired()) {
+ if (flags.testFlag(Notifications::ClearExpired) && (notification.expired() || notification.wasAddedDuringInhibition())) {
close(notification.id());
}
}
diff --git a/libnotificationmanager/libnotificationmanager.notifyrc b/libnotificationmanager/libnotificationmanager.notifyrc
new file mode 100644
index 0000000000..79304e62bc
--- /dev/null
+++ b/libnotificationmanager/libnotificationmanager.notifyrc
@@ -0,0 +1,11 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: None
+
+[Global]
+Name=Notification Manager
+IconName=preferences-desktop-notification-bell
+
+[Event/inhibitionSummary]
+Name=Summary for unread inhibited notifications
+Action=Popup
+Urgency=Low
diff --git a/libnotificationmanager/notification.cpp b/libnotificationmanager/notification.cpp
index 276611310c..320c837617 100644
--- a/libnotificationmanager/notification.cpp
+++ b/libnotificationmanager/notification.cpp
@@ -826,3 +826,13 @@ void Notification::processHints(const QVariantMap &hints)
{
d->processHints(hints);
}
+
+bool Notification::wasAddedDuringInhibition() const
+{
+ return d->wasAddedDuringInhibition;
+}
+
+void Notification::setWasAddedDuringInhibition(bool value)
+{
+ d->wasAddedDuringInhibition = value;
+}
diff --git a/libnotificationmanager/notification.h b/libnotificationmanager/notification.h
index 9f04a4e200..8613534fd2 100644
--- a/libnotificationmanager/notification.h
+++ b/libnotificationmanager/notification.h
@@ -131,6 +131,9 @@ public:
void processHints(const QVariantMap &hints);
+ bool wasAddedDuringInhibition() const;
+ void setWasAddedDuringInhibition(bool value);
+
private:
friend class NotificationsModel;
friend class AbstractNotificationsModel;
diff --git a/libnotificationmanager/notification_p.h b/libnotificationmanager/notification_p.h
index 6cae23c21e..923bde7882 100644
--- a/libnotificationmanager/notification_p.h
+++ b/libnotificationmanager/notification_p.h
@@ -96,6 +96,8 @@ public:
bool resident = false;
bool transient = false;
+
+ bool wasAddedDuringInhibition = false;
};
} // namespace NotificationManager
diff --git a/libnotificationmanager/notificationfilterproxymodel.cpp b/libnotificationmanager/notificationfilterproxymodel.cpp
index 98a32b6645..bb573713af 100644
--- a/libnotificationmanager/notificationfilterproxymodel.cpp
+++ b/libnotificationmanager/notificationfilterproxymodel.cpp
@@ -58,6 +58,20 @@ void NotificationFilterProxyModel::setShowDismissed(bool show)
}
}
+bool NotificationFilterProxyModel::showAddedDuringInhibition() const
+{
+ return m_showDismissed;
+}
+
+void NotificationFilterProxyModel::setShowAddedDuringInhibition(bool show)
+{
+ if (m_showAddedDuringInhibition != show) {
+ m_showAddedDuringInhibition = show;
+ invalidateFilter();
+ Q_EMIT showAddedDuringInhibitionChanged();
+ }
+}
+
QStringList NotificationFilterProxyModel::blacklistedDesktopEntries() const
{
return m_blacklistedDesktopEntries;
@@ -177,5 +191,9 @@ bool NotificationFilterProxyModel::filterAcceptsRow(int source_row, const QModel
}
}
+ if (!m_showAddedDuringInhibition && sourceIdx.data(Notifications::WasAddedDuringInhibitionRole).toBool()) {
+ return false;
+ }
+
return true;
}
diff --git a/libnotificationmanager/notificationfilterproxymodel_p.h b/libnotificationmanager/notificationfilterproxymodel_p.h
index 4029320e8e..af04a9fac1 100644
--- a/libnotificationmanager/notificationfilterproxymodel_p.h
+++ b/libnotificationmanager/notificationfilterproxymodel_p.h
@@ -30,6 +30,9 @@ public:
bool showDismissed() const;
void setShowDismissed(bool show);
+ bool showAddedDuringInhibition() const;
+ void setShowAddedDuringInhibition(bool show);
+
QStringList blacklistedDesktopEntries() const;
void setBlackListedDesktopEntries(const QStringList &blacklist);
@@ -46,6 +49,7 @@ Q_SIGNALS:
void urgenciesChanged();
void showExpiredChanged();
void showDismissedChanged();
+ void showAddedDuringInhibitionChanged();
void blacklistedDesktopEntriesChanged();
void blacklistedNotifyRcNamesChanged();
void whitelistedDesktopEntriesChanged();
@@ -58,6 +62,7 @@ private:
Notifications::Urgencies m_urgencies = Notifications::LowUrgency | Notifications::NormalUrgency | Notifications::CriticalUrgency;
bool m_showDismissed = false;
bool m_showExpired = false;
+ bool m_showAddedDuringInhibition = true;
QStringList m_blacklistedDesktopEntries;
QStringList m_blacklistedNotifyRcNames;
diff --git a/libnotificationmanager/notifications.cpp b/libnotificationmanager/notifications.cpp
index 9b3e18018b..3d5662f926 100644
--- a/libnotificationmanager/notifications.cpp
+++ b/libnotificationmanager/notifications.cpp
@@ -12,6 +12,8 @@
#include <memory>
#include <KDescendantsProxyModel>
+#include <KLocalizedString>
+#include <KNotification>
#include "limitedrowcountproxymodel_p.h"
#include "notificationfilterproxymodel_p.h"
@@ -30,6 +32,7 @@
#include "debug.h"
+using namespace Qt::StringLiterals;
using namespace NotificationManager;
class Q_DECL_HIDDEN Notifications::Private
@@ -166,6 +169,7 @@ void Notifications::Private::initProxyModels()
connect(filterModel, &NotificationFilterProxyModel::urgenciesChanged, q, &Notifications::urgenciesChanged);
connect(filterModel, &NotificationFilterProxyModel::showExpiredChanged, q, &Notifications::showExpiredChanged);
connect(filterModel, &NotificationFilterProxyModel::showDismissedChanged, q, &Notifications::showDismissedChanged);
+ connect(filterModel, &NotificationFilterProxyModel::showAddedDuringInhibitionChanged, q, &Notifications::showAddedDuringInhibitionChanged);
connect(filterModel, &NotificationFilterProxyModel::blacklistedDesktopEntriesChanged, q, &Notifications::blacklistedDesktopEntriesChanged);
connect(filterModel, &NotificationFilterProxyModel::blacklistedNotifyRcNamesChanged, q, &Notifications::blacklistedNotifyRcNamesChanged);
@@ -245,7 +249,7 @@ void Notifications::Private::updateCount()
for (int i = 0; i < filterModel->rowCount(); ++i) {
const QModelIndex idx = filterModel->index(i, 0);
- if (idx.data(Notifications::ExpiredRole).toBool()) {
+ if (idx.data(Notifications::ExpiredRole).toBool() || idx.data(Notifications::WasAddedDuringInhibitionRole).toBool()) {
++expired;
} else {
++active;
@@ -477,6 +481,16 @@ void Notifications::setShowDismissed(bool show)
d->filterModel->setShowDismissed(show);
}
+bool Notifications::showAddedDuringInhibition() const
+{
+ return d->filterModel->showAddedDuringInhibition();
+}
+
+void Notifications::setShowAddedDuringInhibition(bool show)
+{
+ d->filterModel->setShowAddedDuringInhibition(show);
+}
+
QStringList Notifications::blacklistedDesktopEntries() const
{
return d->filterModel->blacklistedDesktopEntries();
@@ -812,6 +826,28 @@ void Notifications::collapseAllGroups()
}
}
+void Notifications::showInhibitionSummary()
+{
+ int inhibited = 0;
+ for (int i = 0, count = d->notificationsAndJobsModel->rowCount(); i < count; ++i) {
+ const QModelIndex idx = d->notificationsAndJobsModel->index(i, 0);
+ if (!idx.data(Notifications::ReadRole).toBool() && idx.data(Notifications::WasAddedDuringInhibitionRole).toBool()) {
+ ++inhibited;
+ }
+ }
+
+ if (!inhibited) {
+ return;
+ }
+
+ KNotification::event(u"inhibitionSummary"_s,
+ i18nc("@title", "Unread Notifications"),
+ i18nc("@info", "%1 notifications were received while Do Not Disturb was active.", QString::number(inhibited)),
+ u"preferences-desktop-notification-bell"_s,
+ KNotification::CloseOnTimeout,
+ u"libnotificationmanager"_s);
+}
+
QVariant Notifications::data(const QModelIndex &index, int role) const
{
return QSortFilterProxyModel::data(index, role);
diff --git a/libnotificationmanager/notifications.h b/libnotificationmanager/notifications.h
index edb898988f..ef500b0c7b 100644
--- a/libnotificationmanager/notifications.h
+++ b/libnotificationmanager/notifications.h
@@ -61,6 +61,15 @@ class NOTIFICATIONMANAGER_EXPORT Notifications : public QSortFilterProxyModel, p
*/
Q_PROPERTY(bool showDismissed READ showDismissed WRITE setShowDismissed NOTIFY showDismissedChanged)
+ /**
+ * Whether to show notifications added during inhibition.
+ *
+ * If set to @c false, notifications are suppressed even after leaving "Do not disturb" mode.
+ *
+ * Default is @c true.
+ */
+ Q_PROPERTY(bool showAddedDuringInhibition READ showAddedDuringInhibition WRITE setShowAddedDuringInhibition NOTIFY showAddedDuringInhibitionChanged)
+
/**
* A list of desktop entries for which no notifications should be shown.
*
@@ -285,6 +294,8 @@ public:
///< notification in a certain way, or group notifications of similar types. @since 5.21
ResidentRole, ///< Whether the notification should keep its actions even when they were invoked. @since 5.22
TransientRole, ///< Whether the notification is transient and should not be kept in history. @since 5.22
+
+ WasAddedDuringInhibitionRole, ///< Whether the notification was added while inhibition was active. @since 6.3
};
Q_ENUM(Roles)
@@ -371,6 +382,9 @@ public:
bool showDismissed() const;
void setShowDismissed(bool show);
+ bool showAddedDuringInhibition() const;
+ void setShowAddedDuringInhibition(bool show);
+
QStringList blacklistedDesktopEntries() const;
void setBlacklistedDesktopEntries(const QStringList &blacklist);
@@ -529,6 +543,11 @@ public:
Q_INVOKABLE void collapseAllGroups();
+ /**
+ * Shows a notification to report the number of unread inhibited notifications.
+ */
+ Q_INVOKABLE void showInhibitionSummary();
+
QVariant data(const QModelIndex &index, int role) const override;
bool setData(const QModelIndex &index, const QVariant &value, int role) override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
@@ -541,6 +560,7 @@ Q_SIGNALS:
void limitChanged();
void showExpiredChanged();
void showDismissedChanged();
+ void showAddedDuringInhibitionChanged();
void blacklistedDesktopEntriesChanged();
void blacklistedNotifyRcNamesChanged();
void whitelistedDesktopEntriesChanged();
diff --git a/libnotificationmanager/server_p.cpp b/libnotificationmanager/server_p.cpp
index 84fe37afa9..66cd621033 100644
--- a/libnotificationmanager/server_p.cpp
+++ b/libnotificationmanager/server_p.cpp
@@ -164,6 +164,7 @@ uint ServerPrivate::Notify(const QString &app_name,
notification.setActions(actions);
notification.setTimeout(timeout);
+ notification.setWasAddedDuringInhibition(m_inhibited);
// might override some of the things we set above (like application name)
notification.d->processHints(hints);