fix: notice input on the lock screen despite idle inhibitors

This commit is contained in:
Felitendo committed 2026-09-22 21:03:41 +02:00
1 parent 7c87adb733
commit 77ae883b98
12 files changed
+280 -38

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Install the build tools - name: Install the build tools
run: | run: |
pacman -Syu --noconfirm --needed git base-devel cmake pkgconf \ pacman -Syu --noconfirm --needed git base-devel cmake pkgconf \
qt6-base qt6-declarative layer-shell-qt kidletime ki18n \ qt6-base qt6-declarative layer-shell-qt ki18n \
opencv pam systemd-libs gettext scdoc shellcheck desktop-file-utils opencv pam systemd-libs gettext scdoc shellcheck desktop-file-utils
- uses: actions/checkout@v7 - uses: actions/checkout@v7
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
git make cmake gcc-c++ gettext scdoc tar curl rpm-build rpm-sign systemd-rpm-macros \ git make cmake gcc-c++ gettext scdoc tar curl rpm-build rpm-sign systemd-rpm-macros \
'pkgconfig(systemd)' 'pkgconfig(libsystemd)' pam-devel opencv-devel \ 'pkgconfig(systemd)' 'pkgconfig(libsystemd)' pam-devel opencv-devel \
qt6-qtbase-devel qt6-qtbase-private-devel qt6-qtdeclarative-devel qt6-qtwayland-devel \ qt6-qtbase-devel qt6-qtbase-private-devel qt6-qtdeclarative-devel qt6-qtwayland-devel \
layer-shell-qt-devel kf6-kidletime-devel kf6-ki18n-devel layer-shell-qt-devel kf6-ki18n-devel
- uses: actions/checkout@v7 - uses: actions/checkout@v7
+4 -3
View File
@@ -136,7 +136,6 @@ if(PFU_BUILD_AGENT)
set(QT_NO_PRIVATE_MODULE_WARNING ON) set(QT_NO_PRIVATE_MODULE_WARNING ON)
find_package(Qt6 REQUIRED COMPONENTS GuiPrivate WaylandClientPrivate) find_package(Qt6 REQUIRED COMPONENTS GuiPrivate WaylandClientPrivate)
find_package(LayerShellQt REQUIRED) find_package(LayerShellQt REQUIRED)
find_package(KF6IdleTime REQUIRED)
find_package(KF6I18n REQUIRED) find_package(KF6I18n REQUIRED)
qt_add_executable(plasma-face-unlock-agent qt_add_executable(plasma-face-unlock-agent
@@ -147,6 +146,7 @@ if(PFU_BUILD_AGENT)
src/agent/bubblewindow.cpp src/agent/bubblewindow.cpp
src/agent/bubblecontroller.cpp src/agent/bubblecontroller.cpp
src/agent/lockcontroller.cpp src/agent/lockcontroller.cpp
src/agent/inputwatcher.cpp
src/agent/enrollcontroller.cpp src/agent/enrollcontroller.cpp
) )
target_include_directories(plasma-face-unlock-agent PRIVATE src/agent) target_include_directories(plasma-face-unlock-agent PRIVATE src/agent)
@@ -155,7 +155,8 @@ if(PFU_BUILD_AGENT)
endif() endif()
qt6_generate_wayland_protocol_client_sources(plasma-face-unlock-agent qt6_generate_wayland_protocol_client_sources(plasma-face-unlock-agent
FILES ${CMAKE_CURRENT_SOURCE_DIR}/protocols/kde-lockscreen-overlay-v1.xml) FILES ${CMAKE_CURRENT_SOURCE_DIR}/protocols/kde-lockscreen-overlay-v1.xml
${CMAKE_CURRENT_SOURCE_DIR}/protocols/ext-idle-notify-v1.xml)
# The QML files sit next to the module's qmldir in the resources, so they # The QML files sit next to the module's qmldir in the resources, so they
# see each other (and the Theme singleton) without importing anything. # see each other (and the Theme singleton) without importing anything.
@@ -189,7 +190,7 @@ if(PFU_BUILD_AGENT)
Qt6::Gui Qt6::GuiPrivate Qt6::Quick Qt6::DBus Qt6::Network Qt6::Gui Qt6::GuiPrivate Qt6::Quick Qt6::DBus Qt6::Network
Qt6::WaylandClient Qt6::WaylandClientPrivate Qt6::WaylandClient Qt6::WaylandClientPrivate
LayerShellQt::Interface LayerShellQt::Interface
KF6::IdleTime KF6::I18n KF6::I18nQml KF6::I18n KF6::I18nQml
pfu_common pfu_common
) )
install(TARGETS plasma-face-unlock-agent DESTINATION ${PFU_LIBEXECDIR}) install(TARGETS plasma-face-unlock-agent DESTINATION ${PFU_LIBEXECDIR})
+1 -1
View File
@@ -207,7 +207,7 @@ sudo make install
``` ```
Needs CMake, a C++20 compiler, Qt 6 (Core, DBus, Network, Gui, Quick, WaylandClient), LayerShellQt, Needs CMake, a C++20 compiler, Qt 6 (Core, DBus, Network, Gui, Quick, WaylandClient), LayerShellQt,
KIdleTime, KI18n, OpenCV 4.5.4 or newer with the DNN module, Linux-PAM and libsystemd. Optionally KI18n, OpenCV 4.5.4 or newer with the DNN module, Linux-PAM and libsystemd. Optionally
`msgfmt` (gettext) for translations and `scdoc` for the man page. Supports `PREFIX` and `DESTDIR`. `msgfmt` (gettext) for translations and `scdoc` for the man page. Supports `PREFIX` and `DESTDIR`.
`make check` runs syntax checks and shellcheck. `make check` runs syntax checks and shellcheck.
+4 -2
View File
@@ -171,7 +171,9 @@ The agent watches for the screen to lock (org.freedesktop.ScreenSaver) and
scans when somebody comes back: scans when somebody comes back:
- on any key or mouse movement after the screen locked, once 1.5 seconds have - on any key or mouse movement after the screen locked, once 1.5 seconds have
passed (the key that locked it does not count); passed (the key that locked it does not count). Enter on the empty password
field is a key like any other. This works while a video or an app keeps the
screen on, too (ext_idle_notifier_v1, input notification);
- when the machine wakes from sleep; - when the machine wakes from sleep;
- right after locking, if *Look right after the screen locks* is on. Off by - right after locking, if *Look right after the screen locks* is on. Off by
default: whoever locks their screen on purpose is usually still in front of default: whoever locks their screen on purpose is usually still in front of
@@ -256,7 +258,7 @@ _$XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket_
# REQUIREMENTS # REQUIREMENTS
KDE Plasma 6 on Wayland, a camera, OpenCV 4.5.4 or newer with its DNN module, KDE Plasma 6 on Wayland, a camera, OpenCV 4.5.4 or newer with its DNN module,
Qt 6, LayerShellQt, KIdleTime, KI18n, systemd, polkit and Linux-PAM. Qt 6, LayerShellQt, KI18n, systemd, polkit and Linux-PAM.
# SEE ALSO # SEE ALSO
+1 -1
View File
@@ -11,7 +11,7 @@ arch=('x86_64' 'aarch64')
url="https://github.com/LoonixTools/plasma-face-unlock" url="https://github.com/LoonixTools/plasma-face-unlock"
license=('GPL-3.0-or-later' 'MIT' 'Apache-2.0') license=('GPL-3.0-or-later' 'MIT' 'Apache-2.0')
depends=('bash' 'coreutils' 'gawk' 'grep' 'sed' 'gettext' 'systemd' 'systemd-libs' 'pam' 'polkit' depends=('bash' 'coreutils' 'gawk' 'grep' 'sed' 'gettext' 'systemd' 'systemd-libs' 'pam' 'polkit'
'opencv' 'qt6-base' 'qt6-declarative' 'layer-shell-qt' 'kidletime' 'ki18n' 'kscreenlocker') 'opencv' 'qt6-base' 'qt6-declarative' 'layer-shell-qt' 'ki18n' 'kscreenlocker')
makedepends=('cmake' 'scdoc') makedepends=('cmake' 'scdoc')
install="${pkgname}.install" install="${pkgname}.install"
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz" source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz"
-1
View File
@@ -34,7 +34,6 @@ BuildRequires: cmake(Qt6WaylandClient)
BuildRequires: qt6-qtbase-private-devel BuildRequires: qt6-qtbase-private-devel
BuildRequires: qt6-qtwayland-devel BuildRequires: qt6-qtwayland-devel
BuildRequires: cmake(LayerShellQt) BuildRequires: cmake(LayerShellQt)
BuildRequires: cmake(KF6IdleTime)
BuildRequires: cmake(KF6I18n) BuildRequires: cmake(KF6I18n)
Requires: bash >= 4.2 Requires: bash >= 4.2
+131
View File
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="ext_idle_notify_v1">
<copyright>
Copyright © 2015 Martin Gräßlin
Copyright © 2022 Simon Ser
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="ext_idle_notifier_v1" version="2">
<description summary="idle notification manager">
This interface allows clients to monitor user idle status.
After binding to this global, clients can create ext_idle_notification_v1
objects to get notified when the user is idle for a given amount of time.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the manager">
Destroy the manager object. All objects created via this interface
remain valid.
</description>
</request>
<request name="get_idle_notification">
<description summary="create a notification object">
Create a new idle notification object.
The notification object has a minimum timeout duration and is tied to a
seat. The client will be notified if the seat is inactive for at least
the provided timeout. See ext_idle_notification_v1 for more details.
A zero timeout is valid and means the client wants to be notified as
soon as possible when the seat is inactive.
</description>
<arg name="id" type="new_id" interface="ext_idle_notification_v1"/>
<arg name="timeout" type="uint" summary="minimum idle timeout in msec"/>
<arg name="seat" type="object" interface="wl_seat"/>
</request>
<!-- Version 2 additions -->
<request name="get_input_idle_notification" since="2">
<description summary="create a notification object">
Create a new idle notification object to track input from the
user, such as keyboard and mouse movement. Because this object is
meant to track user input alone, it ignores idle inhibitors.
The notification object has a minimum timeout duration and is tied to a
seat. The client will be notified if the seat is inactive for at least
the provided timeout. See ext_idle_notification_v1 for more details.
A zero timeout is valid and means the client wants to be notified as
soon as possible when the seat is inactive.
</description>
<arg name="id" type="new_id" interface="ext_idle_notification_v1"/>
<arg name="timeout" type="uint" summary="minimum idle timeout in msec"/>
<arg name="seat" type="object" interface="wl_seat"/>
</request>
</interface>
<interface name="ext_idle_notification_v1" version="2">
<description summary="idle notification">
This interface is used by the compositor to send idle notification events
to clients.
Initially the notification object is not idle. The notification object
becomes idle when no user activity has happened for at least the timeout
duration, starting from the creation of the notification object. User
activity may include input events or a presence sensor, but is
compositor-specific.
How this notification responds to idle inhibitors depends on how
it was constructed. If constructed from the
get_idle_notification request, then if an idle inhibitor is
active (e.g. another client has created a zwp_idle_inhibitor_v1
on a visible surface), the compositor must not make the
notification object idle. However, if constructed from the
get_input_idle_notification request, then idle inhibitors are
ignored, and only input from the user, e.g. from a keyboard or
mouse, counts as activity.
When the notification object becomes idle, an idled event is sent. When
user activity starts again, the notification object stops being idle,
a resumed event is sent and the timeout is restarted.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the notification object">
Destroy the notification object.
</description>
</request>
<event name="idled">
<description summary="notification object is idle">
This event is sent when the notification object becomes idle.
It's a compositor protocol error to send this event twice without a
resumed event in-between.
</description>
</event>
<event name="resumed">
<description summary="notification object is no longer idle">
This event is sent when the notification object stops being idle.
It's a compositor protocol error to send this event twice without an
idled event in-between. It's a compositor protocol error to send this
event prior to any idled event.
</description>
</event>
</interface>
</protocol>
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "inputwatcher.h"
#include <QGuiApplication>
#include <QWaylandClientExtensionTemplate>
#include <QtGui/qguiapplication_platform.h>
#include <functional>
#include "qwayland-ext-idle-notify-v1.h"
class IdleNotifier : public QWaylandClientExtensionTemplate<IdleNotifier>, public QtWayland::ext_idle_notifier_v1
{
public:
IdleNotifier()
: QWaylandClientExtensionTemplate<IdleNotifier>(2)
{
initialize();
}
~IdleNotifier() override
{
if (isActive()) {
destroy();
}
}
};
class IdleNotification : public QtWayland::ext_idle_notification_v1
{
public:
IdleNotification(::ext_idle_notification_v1 *object, std::function<void()> resumed)
: QtWayland::ext_idle_notification_v1(object)
, m_resumed(std::move(resumed))
{
}
~IdleNotification() override
{
destroy();
}
protected:
// Only ever sent after idled: calm first, then this.
void ext_idle_notification_v1_resumed() override
{
if (!m_fired) {
m_fired = true;
m_resumed();
}
}
private:
std::function<void()> m_resumed;
bool m_fired = false;
};
InputWatcher::InputWatcher(QObject *parent)
: QObject(parent)
, m_notifier(std::make_unique<IdleNotifier>())
{
}
InputWatcher::~InputWatcher() = default;
void InputWatcher::watch(int calmMs)
{
m_notification.reset();
auto *wayland = qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>();
if (!m_notifier->isActive() || !wayland || !wayland->seat()) {
qWarning("no ext_idle_notifier_v1, so no telling when somebody comes back");
return;
}
// Version 1 has only the notification that inhibitors hold back.
::ext_idle_notification_v1 *object = m_notifier->QWaylandClientExtension::version() >= 2
? m_notifier->get_input_idle_notification(uint32_t(calmMs), wayland->seat())
: m_notifier->get_idle_notification(uint32_t(calmMs), wayland->seat());
// Queued: not from inside the Wayland event, which the receiver might
// answer by watching anew and so destroying the notification.
m_notification = std::make_unique<IdleNotification>(object, [this] {
QMetaObject::invokeMethod(this, &InputWatcher::input, Qt::QueuedConnection);
});
}
void InputWatcher::stop()
{
m_notification.reset();
}
+37
View File
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Somebody touching the machine: a key, a click, the mouse, the touchpad.
//
// From ext_idle_notifier_v1, like KIdleTime, but with the input notification
// of version 2. KIdleTime's own honours idle inhibitors: with a video playing
// or an app keeping the screen on, no key reached it and the lock screen never
// scanned.
#pragma once
#include <QObject>
#include <memory>
class IdleNotifier;
class IdleNotification;
class InputWatcher : public QObject
{
Q_OBJECT
public:
explicit InputWatcher(QObject *parent = nullptr);
~InputWatcher() override;
// Emit input() once, for the first input after nothing was touched for
// calmMs. 0: the next input. Replaces what was watched before.
void watch(int calmMs);
void stop();
Q_SIGNALS:
void input();
private:
std::unique_ptr<IdleNotifier> m_notifier;
std::unique_ptr<IdleNotification> m_notification;
};
+8 -26
View File
@@ -6,8 +6,6 @@
#include "daemonclient.h" #include "daemonclient.h"
#include "userconfig.h" #include "userconfig.h"
#include <KIdleTime>
#include <QDBusConnection> #include <QDBusConnection>
#include <QDBusMessage> #include <QDBusMessage>
#include <QDBusPendingCallWatcher> #include <QDBusPendingCallWatcher>
@@ -38,7 +36,10 @@ LockController::LockController(BubbleController *bubble, UserConfig *config, QOb
, m_config(config) , m_config(config)
{ {
m_armTimer.setSingleShot(true); m_armTimer.setSingleShot(true);
connect(&m_armTimer, &QTimer::timeout, this, &LockController::arm); connect(&m_armTimer, &QTimer::timeout, this, [this] {
arm(0);
});
connect(&m_input, &InputWatcher::input, this, &LockController::onResume);
QDBusConnection session = QDBusConnection::sessionBus(); QDBusConnection session = QDBusConnection::sessionBus();
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"), session.connect(QStringLiteral("org.freedesktop.ScreenSaver"),
@@ -55,17 +56,6 @@ LockController::LockController(BubbleController *bubble, UserConfig *config, QOb
this, this,
SLOT(onPrepareForSleep(bool))); SLOT(onPrepareForSleep(bool)));
KIdleTime *idle = KIdleTime::instance();
connect(idle, &KIdleTime::resumingFromIdle, this, &LockController::onResume);
connect(idle, qOverload<int, int>(&KIdleTime::timeoutReached), this, [this, idle](int id, int) {
if (id != m_idleId) {
return;
}
idle->removeIdleTimeout(id);
m_idleId = -1;
arm();
});
// Started while the screen is already locked (the agent restarted). // Started while the screen is already locked (the agent restarted).
const QDBusMessage get = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"), const QDBusMessage get = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"), QStringLiteral("/ScreenSaver"),
@@ -86,16 +76,10 @@ void LockController::onActiveChanged(bool active)
if (active == m_locked) { if (active == m_locked) {
return; return;
} }
KIdleTime *idle = KIdleTime::instance();
if (!active) { if (!active) {
m_locked = false; m_locked = false;
m_armTimer.stop(); m_armTimer.stop();
if (m_idleId >= 0) { m_input.stop();
idle->removeIdleTimeout(m_idleId);
m_idleId = -1;
}
idle->stopCatchingResumeEvent();
if (m_scan) { if (m_scan) {
m_scan->abort(); m_scan->abort();
m_scan->deleteLater(); m_scan->deleteLater();
@@ -148,12 +132,12 @@ void LockController::onPrepareForSleep(bool sleeping)
} }
} }
void LockController::arm() void LockController::arm(int calmMs)
{ {
if (!m_locked || m_stopped || !m_config->scanOnWake()) { if (!m_locked || m_stopped || !m_config->scanOnWake()) {
return; return;
} }
KIdleTime::instance()->catchNextResumeEvent(); m_input.watch(calmMs);
} }
void LockController::onResume() void LockController::onResume()
@@ -221,9 +205,7 @@ void LockController::onScanFinished(const QJsonObject &result)
m_armTimer.start(GraceAfterLockMs); m_armTimer.start(GraceAfterLockMs);
return; return;
} }
if (m_idleId < 0) { arm(CalmBeforeRetryMs);
m_idleId = KIdleTime::instance()->addIdleTimeout(CalmBeforeRetryMs);
}
} }
void LockController::unlock() void LockController::unlock()
+5 -2
View File
@@ -19,6 +19,8 @@
#pragma once #pragma once
#include "inputwatcher.h"
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QObject> #include <QObject>
#include <QPointer> #include <QPointer>
@@ -44,7 +46,8 @@ private Q_SLOTS:
void onPrepareForSleep(bool sleeping); void onPrepareForSleep(bool sleeping);
private: private:
void arm(); // Scan at the next input after calmMs without any.
void arm(int calmMs);
void onResume(); void onResume();
void startScan(const QString &why); void startScan(const QString &why);
void onScanFinished(const QJsonObject &result); void onScanFinished(const QJsonObject &result);
@@ -58,5 +61,5 @@ private:
QElapsedTimer m_lockedFor; QElapsedTimer m_lockedFor;
QPointer<DaemonRequest> m_scan; QPointer<DaemonRequest> m_scan;
QTimer m_armTimer; QTimer m_armTimer;
int m_idleId = -1; InputWatcher m_input;
}; };