feat: support gnome, hyprland and niri

This commit is contained in:
Felitendo committed 2026-09-25 09:29:42 +02:00
1 parent bccd1f5510
commit eaec9325af
48 files changed
+4030 -2131

No files matched your search

+7
View File
@@ -51,6 +51,13 @@ QString AgentSocket::path()
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/face-unlock/agent.socket");
}
bool AgentSocket::running()
{
QLocalSocket probe;
probe.connectToServer(path());
return probe.waitForConnected(500);
}
bool AgentSocket::listen()
{
const QString p = path();
+4
View File
@@ -22,6 +22,10 @@ public:
explicit AgentSocket(QObject *parent = nullptr);
bool listen();
// Whether another agent already listens here. Hyprland without a systemd
// session starts the agent from its own config, and that must not make
// two of them.
static bool running();
static QString path();
Q_SIGNALS:
+2 -1
View File
@@ -101,7 +101,8 @@ void BubbleWindow::allowOverLockscreen()
return;
}
if (!m_overlay->isActive()) {
if (!warned) {
// Only KWin has the protocol. Anywhere else there is nothing to fix.
if (!warned && qEnvironmentVariable("XDG_CURRENT_DESKTOP").split(u':').contains(u"KDE")) {
warned = true;
qWarning("KWin does not let this program show above the lock screen; is its desktop file installed?");
}
+4
View File
@@ -11,6 +11,10 @@
// file lists the interface, which the one installed with this does. The
// request has to be made for every new surface role, before it is mapped, so
// it is repeated each time the window is shown again.
//
// Other compositors have no such thing. On Hyprland and Niri the lock screen
// covers the bubble, which shows again with the tick once it is gone. GNOME
// has no layer-shell at all, so there is no bubble there (see main.cpp).
#pragma once
+127 -2
View File
@@ -2,6 +2,10 @@
#include "inputwatcher.h"
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QGuiApplication>
#include <QWaylandClientExtensionTemplate>
#include <QtGui/qguiapplication_platform.h>
@@ -54,6 +58,111 @@ private:
bool m_fired = false;
};
namespace
{
const QString MutterService = QStringLiteral("org.gnome.Mutter.IdleMonitor");
const QString MutterPath = QStringLiteral("/org/gnome/Mutter/IdleMonitor/Core");
} // namespace
// GNOME's way: a watch that fires once nothing was touched for the calm,
// then one that fires at the next input. Each fires once and is gone.
class MutterIdle : public QObject
{
Q_OBJECT
public:
MutterIdle(std::function<void()> input, QObject *parent)
: QObject(parent)
, m_input(std::move(input))
{
QDBusConnection::sessionBus().connect(MutterService, MutterPath, MutterService, QStringLiteral("WatchFired"), this, SLOT(onFired(uint)));
}
~MutterIdle() override
{
stop();
}
void watch(int calmMs)
{
stop();
const int generation = m_generation;
if (calmMs <= 0) {
addWatch(QStringLiteral("AddUserActiveWatch"), {}, generation, &m_active);
return;
}
// Already calm for that long (the scan itself took a while): the
// idle watch would wait for the next calm, so go straight on.
call(QStringLiteral("GetIdletime"), {}, [this, generation, calmMs](const QDBusMessage &reply) {
if (generation != m_generation) {
return;
}
if (reply.arguments().value(0).toULongLong() >= quint64(calmMs)) {
addWatch(QStringLiteral("AddUserActiveWatch"), {}, generation, &m_active);
} else {
addWatch(QStringLiteral("AddIdleWatch"), {QVariant::fromValue(quint64(calmMs))}, generation, &m_idle);
}
});
}
void stop()
{
++m_generation;
remove(m_idle);
remove(m_active);
m_idle = m_active = 0;
}
private Q_SLOTS:
void onFired(uint id)
{
if (id != 0 && id == m_idle) {
remove(m_idle);
m_idle = 0;
addWatch(QStringLiteral("AddUserActiveWatch"), {}, m_generation, &m_active);
} else if (id != 0 && id == m_active) {
m_active = 0;
m_input();
}
}
private:
template<typename Done>
void call(const QString &method, const QVariantList &args, Done done)
{
QDBusMessage msg = QDBusMessage::createMethodCall(MutterService, MutterPath, MutterService, method);
msg.setArguments(args);
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(msg), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher, done] {
watcher->deleteLater();
done(watcher->reply());
});
}
void addWatch(const QString &method, const QVariantList &args, int generation, uint *slot)
{
call(method, args, [this, generation, slot](const QDBusMessage &reply) {
const uint id = reply.arguments().value(0).toUInt();
if (generation != m_generation) {
// Stopped in the meantime.
remove(id);
return;
}
*slot = id;
});
}
void remove(uint id)
{
if (id != 0) {
call(QStringLiteral("RemoveWatch"), {QVariant::fromValue(id)}, [](const QDBusMessage &) { });
}
}
std::function<void()> m_input;
int m_generation = 0;
uint m_idle = 0;
uint m_active = 0;
};
InputWatcher::InputWatcher(QObject *parent)
: QObject(parent)
, m_notifier(std::make_unique<IdleNotifier>())
@@ -64,10 +173,21 @@ InputWatcher::~InputWatcher() = default;
void InputWatcher::watch(int calmMs)
{
m_notification.reset();
stop();
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");
if (!m_mutter && QDBusConnection::sessionBus().interface()->isServiceRegistered(MutterService)) {
m_mutter = new MutterIdle(
[this] {
QMetaObject::invokeMethod(this, &InputWatcher::input, Qt::QueuedConnection);
},
this);
}
if (m_mutter) {
m_mutter->watch(calmMs);
} else {
qWarning("no ext_idle_notifier_v1 and no Mutter IdleMonitor, so no telling when somebody comes back");
}
return;
}
// Version 1 has only the notification that inhibitors hold back.
@@ -84,4 +204,9 @@ void InputWatcher::watch(int calmMs)
void InputWatcher::stop()
{
m_notification.reset();
if (m_mutter) {
m_mutter->stop();
}
}
#include "inputwatcher.moc"
+4 -1
View File
@@ -5,7 +5,8 @@
// 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.
// scanned. GNOME has no ext_idle_notifier_v1; there it is Mutter's own
// IdleMonitor on the session bus, which knows nothing of inhibitors either.
#pragma once
@@ -15,6 +16,7 @@
class IdleNotifier;
class IdleNotification;
class MutterIdle;
class InputWatcher : public QObject
{
@@ -34,4 +36,5 @@ Q_SIGNALS:
private:
std::unique_ptr<IdleNotifier> m_notifier;
std::unique_ptr<IdleNotification> m_notification;
MutterIdle *m_mutter = nullptr;
};
+5 -57
View File
@@ -7,11 +7,7 @@
#include "userconfig.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QJsonObject>
#include <QProcess>
namespace
{
@@ -37,14 +33,7 @@ LockController::LockController(BubbleController *bubble, UserConfig *config, QOb
arm(0);
});
connect(&m_input, &InputWatcher::input, this, &LockController::onResume);
QDBusConnection session = QDBusConnection::sessionBus();
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("ActiveChanged"),
this,
SLOT(onActiveChanged(bool)));
connect(&m_lock, &LockWatcher::lockedChanged, this, &LockController::onLockedChanged);
QDBusConnection::systemBus().connect(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"),
@@ -52,28 +41,14 @@ LockController::LockController(BubbleController *bubble, UserConfig *config, QOb
QStringLiteral("PrepareForSleep"),
this,
SLOT(onPrepareForSleep(bool)));
// Started while the screen is already locked (the agent restarted).
const QDBusMessage get = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("GetActive"));
auto *watcher = new QDBusPendingCallWatcher(session.asyncCall(get), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<bool> reply = *watcher;
if (reply.isValid() && reply.value()) {
onActiveChanged(true);
}
});
}
void LockController::onActiveChanged(bool active)
void LockController::onLockedChanged(bool locked)
{
if (active == m_locked) {
if (locked == m_locked) {
return;
}
if (!active) {
if (!locked) {
m_locked = false;
m_armTimer.stop();
m_input.stop();
@@ -189,7 +164,7 @@ void LockController::onScanFinished(const QJsonObject &result)
// moment to go, and the bubble stays above the desktop, so the rings
// and the tick play on over it.
m_bubble->succeeded();
unlock();
m_lock.unlock();
return;
}
@@ -207,30 +182,3 @@ void LockController::onScanFinished(const QJsonObject &result)
}
arm(CalmBeforeRetryMs);
}
void LockController::unlock()
{
if (!m_locked) {
return;
}
// "auto" is the caller's own session, or for a program outside any
// session (this one runs as a user service) the session on the display.
const QDBusMessage call = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1/session/auto"),
QStringLiteral("org.freedesktop.login1.Session"),
QStringLiteral("Unlock"));
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher] {
watcher->deleteLater();
const QDBusPendingReply<> reply = *watcher;
if (reply.isError()) {
qWarning("logind would not unlock: %s; trying loginctl", qPrintable(reply.error().message()));
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
QStringList args{QStringLiteral("unlock-session")};
if (!id.isEmpty()) {
args << id;
}
QProcess::startDetached(QStringLiteral("loginctl"), args);
}
});
}
+8 -10
View File
@@ -4,22 +4,20 @@
//
// When the screen locks, this waits for somebody to come back: a key, the
// mouse, the lid opening, the machine waking from sleep. Then it scans, and
// when the face matches it asks logind to unlock the session, which is the
// same request `loginctl unlock-session` makes and which Plasma's screen
// locker has always honoured.
// when the face matches it unlocks the session the way that desktop's lock
// screen wants it (see LockWatcher).
//
// Why not a PAM module in the lock screen, like fingerprints? Plasma runs its
// fingerprint stack in parallel with the password, but only starts it once per
// lock, gives up for good the first time it fails, and labels it "scan your
// fingerprint". Doing it from here means scanning again every time somebody
// sits back down, no wrong label, and a bubble that knows what is going on.
// It does not lower the bar either: any program running as this user can
// already unlock this user's session through logind. Face data and the
// decision stay with the daemon, which runs as root.
// fingerprint". GNOME's, hyprlock's and swaylock's only ask once the password
// is typed. Doing it from here means scanning again every time somebody sits
// back down, no wrong label, and a bubble that knows what is going on.
#pragma once
#include "inputwatcher.h"
#include "lockwatcher.h"
#include <QElapsedTimer>
#include <QObject>
@@ -42,7 +40,7 @@ public:
}
private Q_SLOTS:
void onActiveChanged(bool active);
void onLockedChanged(bool locked);
void onPrepareForSleep(bool sleeping);
private:
@@ -51,7 +49,6 @@ private:
void onResume();
void startScan(const QString &why);
void onScanFinished(const QJsonObject &result);
void unlock();
void warmUp();
BubbleController *m_bubble;
@@ -62,4 +59,5 @@ private:
QPointer<DaemonRequest> m_scan;
QTimer m_armTimer;
InputWatcher m_input;
LockWatcher m_lock;
};
+268
View File
@@ -0,0 +1,268 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "lockwatcher.h"
#include "wayland.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusObjectPath>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QDBusVariant>
#include <QFile>
#include <QProcess>
#include <dirent.h>
#include <signal.h>
#include <sys/stat.h>
#include <unistd.h>
namespace
{
const QString Login1 = QStringLiteral("org.freedesktop.login1");
const QString SessionInterface = QStringLiteral("org.freedesktop.login1.Session");
const QString Properties = QStringLiteral("org.freedesktop.DBus.Properties");
constexpr int PollMs = 1000;
// The lock screens that unlock on SIGUSR1.
bool isLocker(const QByteArray &name)
{
return name == "hyprlock" || name == "swaylock";
}
QByteArray readFile(const QString &path)
{
QFile f(path);
return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
}
pid_t parentOf(pid_t pid)
{
// The fields after the name in brackets, which can hold anything.
const QByteArray stat = readFile(QStringLiteral("/proc/%1/stat").arg(pid));
const qsizetype close = stat.lastIndexOf(')');
return close < 0 ? 0 : pid_t(stat.mid(close + 2).split(' ').value(1).toInt());
}
// The lock screens of this user on this display. Another session of the same
// user has a display of its own, and its lock screen is left alone. A child
// of a lock screen (swaylock checks the password in one) is left out: killed
// by the signal before its parent unlocks, it would take the parent down
// with it, and the screen would stay locked.
QList<pid_t> findLockers()
{
QList<pid_t> found;
DIR *proc = ::opendir("/proc");
if (!proc) {
return found;
}
QByteArray display = qgetenv("WAYLAND_DISPLAY");
if (display.isEmpty()) {
display = "wayland-0";
}
const uid_t me = ::getuid();
while (dirent *entry = ::readdir(proc)) {
const pid_t pid = pid_t(atoi(entry->d_name));
struct stat st;
if (pid <= 0 || ::fstatat(::dirfd(proc), entry->d_name, &st, 0) != 0 || st.st_uid != me) {
continue;
}
const QString dir = QStringLiteral("/proc/%1/").arg(pid);
if (!isLocker(readFile(dir + QStringLiteral("comm")).trimmed())) {
continue;
}
bool sameDisplay = true;
for (const QByteArray &var : readFile(dir + QStringLiteral("environ")).split('\0')) {
if (var.startsWith("WAYLAND_DISPLAY=")) {
sameDisplay = var.mid(16) == display;
break;
}
}
if (sameDisplay) {
found.append(pid);
}
}
::closedir(proc);
QList<pid_t> top;
for (const pid_t pid : std::as_const(found)) {
if (!found.contains(parentOf(pid))) {
top.append(pid);
}
}
return top;
}
} // namespace
LockWatcher::LockWatcher(QObject *parent)
: QObject(parent)
{
QDBusConnection session = QDBusConnection::sessionBus();
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("ActiveChanged"),
this,
SLOT(onScreenSaver(bool)));
// Started while the screen is already locked (the agent restarted).
QDBusMessage get = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("GetActive"));
get.setAutoStartService(false);
auto *watcher = new QDBusPendingCallWatcher(session.asyncCall(get), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<bool> reply = *watcher;
if (reply.isValid() && reply.value()) {
onScreenSaver(true);
}
});
findSession();
if (Wayland::hasGlobal("ext_session_lock_manager_v1")) {
connect(&m_poll, &QTimer::timeout, this, &LockWatcher::pollLockers);
m_poll.start(PollMs);
// Not from here: nobody is connected yet to hear about a lock screen
// that is already up.
QTimer::singleShot(0, this, &LockWatcher::pollLockers);
}
}
void LockWatcher::onScreenSaver(bool active)
{
m_screenSaver = active;
update();
}
void LockWatcher::findSession()
{
// Niri and some others hand their session on to user services. Without
// it, "auto" is the session on the display.
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
if (!id.isEmpty()) {
QDBusMessage call = QDBusMessage::createMethodCall(Login1,
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QStringLiteral("GetSession"));
call << id;
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<QDBusObjectPath> reply = *watcher;
watchSession(reply.isValid() ? reply.value().path() : QStringLiteral("/org/freedesktop/login1/session/auto"));
});
return;
}
QDBusMessage call = QDBusMessage::createMethodCall(Login1, QStringLiteral("/org/freedesktop/login1/session/auto"), Properties, QStringLiteral("Get"));
call << SessionInterface << QStringLiteral("Id");
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<QDBusVariant> reply = *watcher;
if (!reply.isValid()) {
qWarning("logind knows no session for this agent: %s", qPrintable(reply.error().message()));
return;
}
// The signals come from the session's real path, not from "auto".
QDBusMessage call = QDBusMessage::createMethodCall(Login1,
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QStringLiteral("GetSession"));
call << reply.value().variant().toString();
auto *next = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(next, &QDBusPendingCallWatcher::finished, this, [this, next] {
next->deleteLater();
const QDBusPendingReply<QDBusObjectPath> path = *next;
if (path.isValid()) {
watchSession(path.value().path());
}
});
});
}
void LockWatcher::watchSession(const QString &path)
{
m_session = path;
QDBusConnection::systemBus().connect(Login1,
path,
Properties,
QStringLiteral("PropertiesChanged"),
this,
SLOT(onSessionProperties(QString, QVariantMap, QStringList)));
readLockedHint();
}
void LockWatcher::readLockedHint()
{
QDBusMessage call = QDBusMessage::createMethodCall(Login1, m_session, Properties, QStringLiteral("Get"));
call << SessionInterface << QStringLiteral("LockedHint");
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher] {
watcher->deleteLater();
const QDBusPendingReply<QDBusVariant> reply = *watcher;
if (reply.isValid()) {
m_lockedHint = reply.value().variant().toBool();
update();
}
});
}
void LockWatcher::onSessionProperties(const QString &interface, const QVariantMap &changed, const QStringList &invalidated)
{
if (interface != SessionInterface) {
return;
}
if (changed.contains(QStringLiteral("LockedHint"))) {
m_lockedHint = changed.value(QStringLiteral("LockedHint")).toBool();
update();
} else if (invalidated.contains(QStringLiteral("LockedHint"))) {
readLockedHint();
}
}
void LockWatcher::pollLockers()
{
m_lockerRunning = !findLockers().isEmpty();
update();
}
void LockWatcher::update()
{
const bool locked = m_screenSaver || m_lockedHint || m_lockerRunning;
if (locked != m_locked) {
m_locked = locked;
Q_EMIT lockedChanged(locked);
}
}
void LockWatcher::unlock()
{
// Looked up again rather than taken from the last poll: a second is
// long enough for a pid to belong to something else.
for (const pid_t pid : findLockers()) {
::kill(pid, SIGUSR1);
}
const QDBusMessage call = QDBusMessage::createMethodCall(Login1,
m_session.isEmpty() ? QStringLiteral("/org/freedesktop/login1/session/auto") : m_session,
SessionInterface,
QStringLiteral("Unlock"));
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::systemBus().asyncCall(call), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher] {
watcher->deleteLater();
const QDBusPendingReply<> reply = *watcher;
if (reply.isError()) {
qWarning("logind would not unlock: %s; trying loginctl", qPrintable(reply.error().message()));
const QString id = qEnvironmentVariable("XDG_SESSION_ID");
QStringList args{QStringLiteral("unlock-session")};
if (!id.isEmpty()) {
args << id;
}
QProcess::startDetached(QStringLiteral("loginctl"), args);
}
});
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Whether the screen is locked, and how to unlock it, on each desktop.
//
// Plasma org.freedesktop.ScreenSaver and logind's LockedHint. Unlocked
// through logind.
// GNOME logind's LockedHint. Unlocked through logind.
// Niri logind's LockedHint, which niri sets for any lock screen.
// Hyprland sets no LockedHint, so the lock screen is looked for among this
// user's processes (hyprlock, swaylock) once a second. Only on
// compositors where the lock screen is a program of its own
// (ext-session-lock).
//
// hyprlock and swaylock do not listen to logind. They unlock on SIGUSR1.
//
// None of this lowers the bar: any program running as this user can already
// ask logind to unlock the session, or send its own lock screen a signal. The
// face data and the decision stay with the daemon, which runs as root.
#pragma once
#include <QObject>
#include <QTimer>
#include <QVariantMap>
#include <sys/types.h>
class LockWatcher : public QObject
{
Q_OBJECT
public:
explicit LockWatcher(QObject *parent = nullptr);
bool locked() const
{
return m_locked;
}
void unlock();
Q_SIGNALS:
void lockedChanged(bool locked);
private Q_SLOTS:
void onScreenSaver(bool active);
void onSessionProperties(const QString &interface, const QVariantMap &changed, const QStringList &invalidated);
private:
void findSession();
void watchSession(const QString &path);
void readLockedHint();
void pollLockers();
void update();
bool m_screenSaver = false;
bool m_lockedHint = false;
bool m_lockerRunning = false;
bool m_locked = false;
QString m_session;
QTimer m_poll;
};
+17 -2
View File
@@ -14,6 +14,7 @@
#include "enrollcontroller.h"
#include "lockcontroller.h"
#include "userconfig.h"
#include "wayland.h"
#include "buildconfig.h"
@@ -82,7 +83,7 @@ int main(int argc, char **argv)
KLocalizedString::setApplicationDomain(FU_NAME);
QCommandLineParser parser;
parser.setApplicationDescription(i18n("Face unlock for KDE Plasma"));
parser.setApplicationDescription(i18n("Face unlock for the lock screen, sudo and admin prompts"));
parser.addHelpOption();
parser.addVersionOption();
const QCommandLineOption enrollOpt(QStringLiteral("enroll"), i18n("Set up a face."));
@@ -110,14 +111,28 @@ int main(int argc, char **argv)
config.overrideStyle(parser.value(styleOpt));
}
BubbleController bubble(&config);
BubbleWindow window(&engine, &bubble);
// The bubble floats above everything as a layer-shell surface. GNOME has
// none, and a normal window cannot stay on top or pick its place, so
// there it does without.
std::unique_ptr<BubbleWindow> window;
if (Wayland::hasGlobal("zwlr_layer_shell_v1")) {
window = std::make_unique<BubbleWindow>(&engine, &bubble);
}
if (parser.isSet(demoOpt)) {
if (!window) {
qWarning("this desktop has no layer-shell, so there is no bubble to show");
return 1;
}
scheduleDemo(&bubble);
return app.exec();
}
AgentSocket socket;
if (AgentSocket::running()) {
qInfo("an agent is already running in this session");
return 0;
}
socket.listen();
QObject::connect(&socket, &AgentSocket::scanEvent, &bubble, &BubbleController::daemonEvent);
+7 -2
View File
@@ -13,10 +13,15 @@ Window {
required property var controller
// One fixed size: the layout needs no more room, and tiling compositors
// (Hyprland, Niri) float a window that cannot be resized instead of
// stretching it over half the screen.
width: 520
height: 680
minimumWidth: 460
minimumHeight: 620
minimumWidth: width
maximumWidth: width
minimumHeight: height
maximumHeight: height
visible: true
color: Theme.panel
title: i18n("Set up Face Unlock")
+54
View File
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "wayland.h"
#include <QByteArray>
#include <QGuiApplication>
#include <QSet>
#include <wayland-client.h>
namespace
{
void onGlobal(void *data, wl_registry *, uint32_t, const char *interface, uint32_t)
{
static_cast<QSet<QByteArray> *>(data)->insert(QByteArray(interface));
}
void onGlobalRemove(void *, wl_registry *, uint32_t)
{
}
const wl_registry_listener listener = {onGlobal, onGlobalRemove};
const QSet<QByteArray> &globals()
{
static QSet<QByteArray> names;
static bool asked = false;
if (asked) {
return names;
}
asked = true;
auto *app = qGuiApp ? qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>() : nullptr;
wl_display *display = app ? app->display() : nullptr;
if (!display) {
return names;
}
wl_event_queue *queue = wl_display_create_queue(display);
auto *wrapped = static_cast<wl_display *>(wl_proxy_create_wrapper(display));
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(wrapped), queue);
wl_registry *registry = wl_display_get_registry(wrapped);
wl_registry_add_listener(registry, &listener, &names);
wl_display_roundtrip_queue(display, queue);
wl_registry_destroy(registry);
wl_proxy_wrapper_destroy(wrapped);
wl_event_queue_destroy(queue);
return names;
}
} // namespace
bool Wayland::hasGlobal(const char *interface)
{
return globals().contains(QByteArray(interface));
}
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// What the compositor offers. Not every desktop has everything: GNOME has no
// layer-shell (so no bubble) and no ext-idle-notify, and only compositors
// whose lock screen is a program of its own have ext-session-lock.
#pragma once
namespace Wayland
{
// Whether the compositor announces this interface, for example
// "zwlr_layer_shell_v1". Asked once, on an event queue of its own, so Qt's
// own handling of the connection is not disturbed.
bool hasGlobal(const char *interface);
} // namespace Wayland
+1 -1
View File
@@ -71,7 +71,7 @@ int main(int argc, char **argv)
qSetMessagePattern(QStringLiteral("%{if-warning}warning: %{endif}%{if-critical}error: %{endif}%{message}"));
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("Face unlock daemon for KDE Plasma"));
parser.setApplicationDescription(QStringLiteral("Face unlock daemon"));
parser.addHelpOption();
parser.addVersionOption();
const QCommandLineOption socketOpt(QStringLiteral("socket"), QStringLiteral("Listen here instead of the system socket."), QStringLiteral("path"),
+3 -3
View File
@@ -4,9 +4,9 @@
//
// Adding a face is adding a way in, so it asks for the password first, the
// same way a phone asks for its code before it sets up a face. The question
// goes to the polkit agent of the person's own session (on Plasma, the
// familiar password dialog), which is why the daemon never sees the
// password.
// goes to the polkit agent of the person's own session (the desktop's usual
// password dialog), which is why the daemon never sees the password.
// Hyprland and Niri have none of their own: one has to be running there.
#pragma once
+3 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# face-unlock: face unlock for KDE Plasma
# face-unlock: face unlock for Plasma, GNOME, Hyprland and Niri
#
# Look at the screen and it unlocks, the way a phone does. The lock screen,
# sudo in a terminal and the admin password prompts can all take a face
@@ -64,7 +64,7 @@ fu_do_setup() {
fu_ensure_service || return 1
if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
fu_bad "$(fu_msg "Setting up a face needs the camera picture on screen. Run this inside your Plasma session.")"
fu_bad "$(fu_msg "Setting up a face needs the camera picture on screen. Run this inside your desktop session.")"
return 1
fi
@@ -102,6 +102,7 @@ fu_do_enable() {
if fu_agent_available; then
fu_agent_enable || fu_bad "$(fu_msg "Could not start the lock screen agent.")"
fu_agent_autostarts || fu_agent_hint
else
fu_note "$(fu_msg "No systemd user session, so the lock screen agent was not started.")"
fi
+9
View File
@@ -31,6 +31,15 @@ FU_SYSCONFIG="${FU_SYSCONFIG:-/etc/${FU_NAME}/config}"
FU_UNIT_SOCKET="face-unlockd.socket"
FU_UNIT_AGENT="face-unlock-agent.service"
# The desktop this runs in: plasma, gnome, hyprland, niri or other.
case ":${XDG_CURRENT_DESKTOP:-}:" in
*:KDE:*) FU_DESKTOP=plasma ;;
*:GNOME:*) FU_DESKTOP=gnome ;;
*:Hyprland:*) FU_DESKTOP=hyprland ;;
*:niri:*) FU_DESKTOP=niri ;;
*) FU_DESKTOP=other ;;
esac
# ---------------------------------------------------------------------------
# Translations
# ---------------------------------------------------------------------------
+18 -4
View File
@@ -211,7 +211,7 @@ fu_value_label() {
# fu_ui_status
# Shared by the `status` subcommand and the menu header.
fu_ui_status() {
local names='' i camera
local names='' i camera agent=yes
fu_config_load
fu_status_load
@@ -246,6 +246,9 @@ fu_ui_status() {
_fu_row "$(fu_msg "Camera")" "$camera"
_fu_row "$(fu_msg "Lock screen")" "$(_fu_small_onoff "$( [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && echo yes || echo no)")"
if [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && fu_agent_available && ! fu_agent_running; then
agent=no
fi
_fu_row "$(fu_msg "sudo")" "$(_fu_small_onoff "$(fu_pam_enabled sudo && echo yes || echo no)")"
_fu_row "$(fu_msg "Admin prompts")" "$(_fu_small_onoff "$(fu_pam_enabled polkit-1 && echo yes || echo no)")"
_fu_row "$(fu_msg "Photo check")" "$(fu_value_label Liveness "$CFG_LIVENESS")"
@@ -258,6 +261,10 @@ fu_ui_status() {
if [[ $FU_ST_MODELS != true ]]; then
printf '\n %s%s%s\n' "$FU_C_RED" "$(fu_msg "The recognition models are missing. Reinstall the package.")" "$FU_C_RESET"
fi
if [[ $agent == no ]]; then
printf '\n %s%s%s\n' "$FU_C_YELLOW" "$(fu_msg "The lock screen agent is not running.")" "$FU_C_RESET"
fu_agent_autostarts || fu_agent_hint
fi
}
# ---------------------------------------------------------------------------
@@ -269,7 +276,8 @@ fu_ui_status() {
# heading, with only the label after it
# type bool, choice (steps through the choices) or camera
# needs a bool setting this one does nothing without; it is dimmed while
# that is off
# that is off. Layers is no setting: whether the desktop can show
# the bubble at all (GNOME cannot).
FU_SETTINGS=(
"group|Lock screen"
"user|LockScreen|bool|yes|Unlock with your face"
@@ -287,7 +295,7 @@ FU_SETTINGS=(
"sys|Adapt|bool|yes|Learn from every unlock"
"sys|SkipLidClosed|bool|yes|Not when the lid is closed"
"group|Bubble"
"user|Bubble|bool|yes|Show the bubble"
"user|Bubble|bool|yes|Show the bubble||Layers"
"user|BubbleStyle|choice|full|Style|full,minimal|Bubble"
"user|AnimationSpeed|choice|normal|Animation speed|slow,normal,fast|Bubble"
"user|BubbleForPrompts|bool|yes|Also for sudo and admin prompts||Bubble"
@@ -296,12 +304,16 @@ FU_SETTINGS=(
# fu_setting_help <Key> <value>
# What a setting does, shown under the list for the selected one.
fu_setting_help() {
if [[ $FU_DESKTOP == gnome && $1 =~ ^(Bubble|BubbleStyle|AnimationSpeed|BubbleForPrompts)$ ]]; then
fu_msg "GNOME does not let other programs show above its windows, so there is no bubble on GNOME."
return
fi
case "$1:$2" in
LockScreen:*) fu_msg "Unlocks the lock screen when it sees your face. Off: only your password works there." ;;
ScanOnWake:*) fu_msg "Scans when you press a key or move the mouse on the lock screen, and when the computer wakes up." ;;
ScanOnLock:*) fu_msg "Scans as soon as the screen locks. Off by default: if you lock it yourself, it would unlock again right away." ;;
sudo:*) fu_msg "sudo takes your face instead of the password. No match: you type the password as usual." ;;
polkit-1:*) fu_msg "The password windows of Plasma and apps, for example when you install software. No match: you type the password." ;;
polkit-1:*) fu_msg "The password windows of your desktop and apps, for example when you install software. No match: you type the password." ;;
Liveness:heavy) fu_msg "You have to blink or turn your head a little. This also stops printed photos." ;;
Liveness:off) fu_msg "No check at all. Only for trying out a camera." ;;
Liveness:*) fu_msg "Stops photos on a phone, a tablet or glossy paper. You do not have to blink. A matte printed photo can get through." ;;
@@ -511,6 +523,8 @@ fu_ui_settings() {
values[i]="$FU_SETTING_VALUE"
current[${keys[i]}]="$FU_SETTING_VALUE"
done
current[Layers]=yes
[[ $FU_DESKTOP == gnome ]] && current[Layers]=no current[Bubble]=no
dirty=0
fi
+15
View File
@@ -117,6 +117,21 @@ fu_agent_enable() {
systemctl --user enable --now "$FU_UNIT_AGENT" > /dev/null 2>&1
}
# fu_agent_autostarts
# Whether the agent's service starts with the session. It is wanted by
# graphical-session.target, which Hyprland only reaches when it runs under
# uwsm. Plasma, GNOME and niri-session always do.
fu_agent_autostarts() {
[[ $FU_DESKTOP != hyprland ]] || systemctl --user is-active --quiet graphical-session.target
}
# fu_agent_hint
fu_agent_hint() {
# shellcheck disable=SC2088 # shown to the user, not a path to open
fu_note "$(fu_msg "Hyprland does not start the lock screen agent by itself. Add this line to %s:" "~/.config/hypr/hyprland.conf")"
fu_say " exec-once = systemctl --user start $FU_UNIT_AGENT"
}
fu_agent_disable() {
systemctl --user disable --now "$FU_UNIT_AGENT" > /dev/null 2>&1 || true
}