feat: add plasma-face-unlock

This commit is contained in:
Felitendo committed 2026-09-22 19:39:04 +02:00
commit f671acc93b
105 files changed
+13862

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "agentsocket.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QLocalSocket>
#include <QStandardPaths>
#include <sys/socket.h>
#include <unistd.h>
AgentSocket::AgentSocket(QObject *parent)
: QObject(parent)
{
connect(&m_server, &QLocalServer::newConnection, this, [this] {
while (QLocalSocket *socket = m_server.nextPendingConnection()) {
ucred cred{};
socklen_t len = sizeof(cred);
if (::getsockopt(int(socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0
|| (cred.uid != 0 && cred.uid != ::getuid())) {
socket->abort();
socket->deleteLater();
continue;
}
auto buffer = std::make_shared<QByteArray>();
connect(socket, &QLocalSocket::readyRead, this, [this, socket, buffer] {
*buffer += socket->readAll();
if (buffer->size() > 64 * 1024) {
socket->abort();
return;
}
qsizetype nl;
while ((nl = buffer->indexOf('\n')) >= 0) {
const QJsonObject o = QJsonDocument::fromJson(buffer->left(nl)).object();
buffer->remove(0, nl + 1);
if (o.value(u"event").toString() == u"scan") {
Q_EMIT scanEvent(o);
}
}
});
connect(socket, &QLocalSocket::disconnected, socket, &QObject::deleteLater);
}
});
}
QString AgentSocket::path()
{
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/plasma-face-unlock/agent.socket");
}
bool AgentSocket::listen()
{
const QString p = path();
QDir().mkpath(QFileInfo(p).absolutePath());
QFile::setPermissions(QFileInfo(p).absolutePath(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
QLocalServer::removeServer(p);
// Anybody may write to the socket itself, because the directory around it
// lets nobody but this user in. The daemon gets through that directory with
// CAP_DAC_READ_SEARCH, which allows passing through but not writing to
// something that is not open to others. Who is on the other end is checked
// on every connection anyway.
m_server.setSocketOptions(QLocalServer::WorldAccessOption);
if (!m_server.listen(p)) {
qWarning("cannot listen on %s: %s", qPrintable(p), qPrintable(m_server.errorString()));
return false;
}
return true;
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Where the daemon tells this session about scans it did not ask for: sudo in
// a terminal, an admin prompt, a test from the menu. The daemon connects to
// $XDG_RUNTIME_DIR/plasma-face-unlock/agent.socket when such a scan starts,
// so neither side has to keep a connection open (and the daemon can exit when
// it is idle).
//
// Only root and this user may talk here, and all they can do is make the
// bubble move.
#pragma once
#include <QJsonObject>
#include <QLocalServer>
#include <QObject>
class AgentSocket : public QObject
{
Q_OBJECT
public:
explicit AgentSocket(QObject *parent = nullptr);
bool listen();
static QString path();
Q_SIGNALS:
void scanEvent(const QJsonObject &event);
private:
QLocalServer m_server;
};
+171
View File
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "bubblecontroller.h"
#include "userconfig.h"
#include <KLocalizedString>
#include <QJsonObject>
namespace
{
// How long a result stays up before the bubble closes. Long enough to read,
// short enough not to be in the way.
constexpr int SuccessHoldMs = 900;
constexpr int FailureHoldMs = 1700;
constexpr int LockoutHoldMs = 3000;
// A scan that never reports back (the daemon went away half way) must not
// leave the bubble up for good.
constexpr int ScanWatchdogMs = 30000;
} // namespace
BubbleController::BubbleController(UserConfig *config, QObject *parent)
: QObject(parent)
, m_config(config)
{
m_hide.setSingleShot(true);
connect(&m_hide, &QTimer::timeout, this, &BubbleController::dismiss);
connect(m_config, &UserConfig::changed, this, &BubbleController::styleChanged);
}
QString BubbleController::style() const
{
return m_config->bubbleStyle();
}
bool BubbleController::enabled() const
{
return m_config->bubble();
}
void BubbleController::setPhase(const QString &phase)
{
if (m_phase == phase) {
return;
}
m_phase = phase;
if (phase != u"hidden" && !m_shown) {
m_shown = true;
Q_EMIT shownChanged();
}
Q_EMIT phaseChanged();
}
void BubbleController::setMessage(const QString &message)
{
if (m_message != message) {
m_message = message;
Q_EMIT messageChanged();
}
}
void BubbleController::scanStarted()
{
if (!enabled()) {
return;
}
m_hide.start(ScanWatchdogMs);
setMessage({});
if (m_faceSeen) {
m_faceSeen = false;
Q_EMIT faceSeenChanged();
}
setPhase(QStringLiteral("scanning"));
}
void BubbleController::faceFound()
{
if (m_phase == u"scanning" && !m_faceSeen) {
m_faceSeen = true;
Q_EMIT faceSeenChanged();
}
}
void BubbleController::hint(const QString &hint)
{
if (m_phase != u"scanning") {
return;
}
if (hint == u"blink") {
setMessage(i18n("Blink once"));
} else if (hint == u"look") {
setMessage(i18n("Look at the screen"));
} else if (hint == u"closer") {
setMessage(i18n("Move closer"));
} else if (hint == u"light") {
setMessage(i18n("Too dark"));
}
}
void BubbleController::succeeded()
{
if (m_phase == u"hidden") {
return;
}
setMessage({});
setPhase(QStringLiteral("success"));
m_hide.start(SuccessHoldMs);
}
void BubbleController::failed(const QString &reason, qint64 lockout)
{
if (m_phase == u"hidden") {
return;
}
if (lockout > 0 || reason == u"lockout") {
setMessage(i18n("Use your password"));
setPhase(QStringLiteral("lockout"));
m_hide.start(LockoutHoldMs);
return;
}
if (reason == u"mismatch" || reason == u"spoof") {
setMessage(i18n("Not recognized"));
} else if (reason == u"liveness") {
setMessage(i18n("Try again and blink"));
} else if (reason == u"camera") {
setMessage(i18n("Camera unavailable"));
} else {
// Nobody there, a head turned away, the scan cancelled because the
// password was typed: nothing worth saying. The bubble just goes.
setMessage({});
setPhase(QStringLiteral("hidden"));
m_hide.stop();
return;
}
setPhase(QStringLiteral("failure"));
m_hide.start(FailureHoldMs);
}
void BubbleController::dismiss()
{
m_hide.stop();
setPhase(QStringLiteral("hidden"));
}
void BubbleController::closed()
{
if (m_phase == u"hidden" && m_shown) {
m_shown = false;
Q_EMIT shownChanged();
}
}
void BubbleController::daemonEvent(const QJsonObject &event)
{
if (!m_config->bubbleForPrompts()) {
return;
}
const QString state = event.value(u"state").toString();
if (state == u"start") {
scanStarted();
} else if (state == u"face") {
faceFound();
} else if (state == u"hint") {
hint(event.value(u"hint").toString());
} else if (state == u"success") {
succeeded();
} else if (state == u"failure") {
failed(event.value(u"reason").toString(), qint64(event.value(u"lockout").toDouble()));
}
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// What the bubble shows, and for how long.
//
// Two things drive it: the lock screen (LockController, which runs its own
// scans) and the daemon, which tells the agent about every other scan (sudo,
// an admin prompt, a test from the menu). Both speak through the same few
// calls, so the bubble looks the same whatever asked for the scan.
#pragma once
#include <QObject>
#include <QTimer>
class UserConfig;
class BubbleController : public QObject
{
Q_OBJECT
// hidden, scanning, success, failure, lockout
Q_PROPERTY(QString phase READ phase NOTIFY phaseChanged)
// A short line under the face in the full style: a hint while
// scanning, the reason after a failure.
Q_PROPERTY(QString message READ message NOTIFY messageChanged)
Q_PROPERTY(bool faceSeen READ faceSeen NOTIFY faceSeenChanged)
Q_PROPERTY(QString style READ style NOTIFY styleChanged)
// Whether the window should be on screen. Stays true after the phase
// goes back to hidden until the bubble has finished closing.
Q_PROPERTY(bool shown READ shown NOTIFY shownChanged)
public:
explicit BubbleController(UserConfig *config, QObject *parent = nullptr);
QString phase() const
{
return m_phase;
}
QString message() const
{
return m_message;
}
bool faceSeen() const
{
return m_faceSeen;
}
QString style() const;
bool shown() const
{
return m_shown;
}
void scanStarted();
void faceFound();
void hint(const QString &hint);
void succeeded();
// reason is the daemon's; lockout is seconds left when there is one.
void failed(const QString &reason, qint64 lockout = 0);
void dismiss();
// An event from the daemon about a scan somebody else asked for.
void daemonEvent(const QJsonObject &event);
// The QML side calls this when the closing animation is over.
Q_INVOKABLE void closed();
Q_SIGNALS:
void phaseChanged();
void messageChanged();
void faceSeenChanged();
void styleChanged();
void shownChanged();
private:
void setPhase(const QString &phase);
void setMessage(const QString &message);
bool enabled() const;
UserConfig *m_config;
QString m_phase = QStringLiteral("hidden");
QString m_message;
bool m_faceSeen = false;
bool m_shown = false;
QTimer m_hide;
};
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "bubblewindow.h"
#include "bubblecontroller.h"
#include <LayerShellQt/Window>
#include <QGuiApplication>
#include <QQuickView>
#include <QWaylandClientExtensionTemplate>
#include <qpa/qplatformwindow_p.h>
#include "qwayland-kde-lockscreen-overlay-v1.h"
namespace
{
// Big enough for the open island plus its shadow. Only the content moves;
// the window itself never changes size, which keeps the compositor from
// having to reconfigure the surface in the middle of an animation.
constexpr int WindowWidth = 420;
constexpr int WindowHeight = 300;
} // namespace
class LockscreenOverlay : public QWaylandClientExtensionTemplate<LockscreenOverlay>, public QtWayland::kde_lockscreen_overlay_v1
{
public:
LockscreenOverlay()
: QWaylandClientExtensionTemplate<LockscreenOverlay>(1)
{
initialize();
}
~LockscreenOverlay() override
{
if (isActive()) {
destroy();
}
}
};
BubbleWindow::BubbleWindow(QQmlEngine *engine, BubbleController *controller, QObject *parent)
: QObject(parent)
, m_engine(engine)
, m_controller(controller)
, m_overlay(std::make_unique<LockscreenOverlay>())
{
connect(m_controller, &BubbleController::shownChanged, this, &BubbleWindow::update);
create();
}
BubbleWindow::~BubbleWindow()
{
delete m_view;
}
void BubbleWindow::create()
{
m_view = new QQuickView(m_engine, nullptr);
m_view->setColor(Qt::transparent);
m_view->setFlags(Qt::FramelessWindowHint | Qt::WindowDoesNotAcceptFocus | Qt::WindowTransparentForInput);
m_view->resize(WindowWidth, WindowHeight);
m_view->setScreen(QGuiApplication::primaryScreen());
if (auto *layer = LayerShellQt::Window::get(m_view)) {
layer->setLayer(LayerShellQt::Window::LayerOverlay);
layer->setAnchors(LayerShellQt::Window::AnchorTop);
// -1: sit at the very top edge, over a top panel if there is one,
// rather than being pushed down below it.
layer->setExclusiveZone(-1);
layer->setKeyboardInteractivity(LayerShellQt::Window::KeyboardInteractivityNone);
layer->setActivateOnShow(false);
layer->setScope(QStringLiteral("plasma-face-unlock-bubble"));
#ifdef PFU_LAYERSHELL_HAS_SCREEN
layer->setScreen(QGuiApplication::primaryScreen());
#endif
}
m_view->setInitialProperties({{QStringLiteral("bubble"), QVariant::fromValue(m_controller)}});
m_view->loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Bubble"));
if (m_view->status() == QQuickView::Error) {
for (const QQmlError &e : m_view->errors()) {
qWarning("%s", qPrintable(e.toString()));
}
}
m_view->create();
if (auto *wayland = m_view->nativeInterface<QNativeInterface::Private::QWaylandWindow>()) {
connect(wayland, &QNativeInterface::Private::QWaylandWindow::surfaceRoleCreated, this, &BubbleWindow::allowOverLockscreen);
}
}
void BubbleWindow::allowOverLockscreen()
{
static bool warned = false;
auto *wayland = m_view ? m_view->nativeInterface<QNativeInterface::Private::QWaylandWindow>() : nullptr;
if (!wayland || !wayland->surface()) {
return;
}
if (!m_overlay->isActive()) {
if (!warned) {
warned = true;
qWarning("KWin does not let this program show above the lock screen; is its desktop file installed?");
}
return;
}
m_overlay->allow(wayland->surface());
}
void BubbleWindow::update()
{
if (!m_view) {
return;
}
if (m_controller->shown()) {
m_view->show();
} else {
m_view->hide();
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The window the bubble lives in.
//
// A layer-shell surface on the overlay layer, anchored to the top edge and
// transparent to input, so it floats above everything the way the island on a
// phone does and never takes a click or a key from what is under it.
//
// On top of that it asks KWin to keep showing it while the screen is locked
// (kde_lockscreen_overlay_v1). KWin only grants that to programs whose desktop
// 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.
#pragma once
#include <QObject>
#include <QPointer>
#include <memory>
class QQuickView;
class QQmlEngine;
class BubbleController;
class LockscreenOverlay;
class BubbleWindow : public QObject
{
Q_OBJECT
public:
BubbleWindow(QQmlEngine *engine, BubbleController *controller, QObject *parent = nullptr);
~BubbleWindow() override;
private:
void create();
void update();
void allowOverLockscreen();
QQmlEngine *m_engine;
BubbleController *m_controller;
QPointer<QQuickView> m_view;
std::unique_ptr<LockscreenOverlay> m_overlay;
};
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "daemonclient.h"
#include "buildconfig.h"
#include <QJsonDocument>
DaemonRequest::DaemonRequest(const QJsonObject &request, QObject *parent)
: QObject(parent)
, m_request(request)
{
connect(&m_socket, &QLocalSocket::connected, this, [this] {
m_socket.write(QJsonDocument(m_request).toJson(QJsonDocument::Compact) + '\n');
});
connect(&m_socket, &QLocalSocket::readyRead, this, &DaemonRequest::readLines);
connect(&m_socket, &QLocalSocket::errorOccurred, this, [this](QLocalSocket::LocalSocketError error) {
if (error == QLocalSocket::PeerClosedError) {
return;
}
end({{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("unreachable")},
{QStringLiteral("message"), m_socket.errorString()}});
});
connect(&m_socket, &QLocalSocket::disconnected, this, [this] {
readLines();
end({{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), false}, {QStringLiteral("reason"), QStringLiteral("disconnected")}});
});
m_socket.connectToServer(socketPath());
}
DaemonRequest::~DaemonRequest()
{
m_done = true;
m_socket.abort();
}
QString DaemonRequest::socketPath()
{
const QByteArray env = qgetenv("PFU_SOCKET");
return env.isEmpty() ? QStringLiteral(PFU_SOCKET) : QString::fromLocal8Bit(env);
}
void DaemonRequest::send(const QJsonObject &message)
{
if (m_socket.state() == QLocalSocket::ConnectedState) {
m_socket.write(QJsonDocument(message).toJson(QJsonDocument::Compact) + '\n');
m_socket.flush();
}
}
void DaemonRequest::abort()
{
m_done = true;
m_socket.abort();
}
void DaemonRequest::readLines()
{
if (m_socket.isOpen() && m_socket.bytesAvailable() > 0) {
m_buffer += m_socket.readAll();
}
qsizetype nl;
while (!m_done && (nl = m_buffer.indexOf('\n')) >= 0) {
const QJsonObject o = QJsonDocument::fromJson(m_buffer.left(nl)).object();
m_buffer.remove(0, nl + 1);
if (o.value(u"event").toString() == u"result") {
end(o);
return;
}
Q_EMIT event(o);
}
}
void DaemonRequest::end(const QJsonObject &result)
{
if (m_done) {
return;
}
m_done = true;
Q_EMIT finished(result);
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Talking to the daemon: one connection per request, the way the daemon
// wants it. The connection lives as long as the request does, so closing it
// is also how a scan gets cancelled.
#pragma once
#include <QJsonObject>
#include <QLocalSocket>
#include <QObject>
class DaemonRequest : public QObject
{
Q_OBJECT
public:
DaemonRequest(const QJsonObject &request, QObject *parent);
~DaemonRequest() override;
// Something for the request that is already running ("cancel",
// "finish").
void send(const QJsonObject &message);
// Stop without waiting for an answer. finished() is not emitted.
void abort();
static QString socketPath();
Q_SIGNALS:
void event(const QJsonObject &event);
// The last message: the daemon's result, or one made up here when the
// daemon could not be reached or went away ("unreachable",
// "disconnected").
void finished(const QJsonObject &result);
private:
void readLines();
void end(const QJsonObject &result);
QLocalSocket m_socket;
QByteArray m_buffer;
QJsonObject m_request;
bool m_done = false;
};
+209
View File
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "enrollcontroller.h"
#include "daemonclient.h"
#include <KLocalizedString>
#include <QJsonObject>
EnrollController::EnrollController(QObject *parent)
: QObject(parent)
{
}
void EnrollController::setName(const QString &name)
{
if (m_name != name) {
m_name = name;
Q_EMIT nameChanged();
}
}
QVariantList EnrollController::sectors() const
{
QVariantList list;
for (bool s : m_sectors) {
list.append(s);
}
return list;
}
int EnrollController::sectorsDone() const
{
int n = 0;
for (bool s : m_sectors) {
n += s;
}
return n;
}
bool EnrollController::canFinish() const
{
// The daemon's own minimum (EnrollJob::SectorsForEarlyFinish).
return m_state == u"circle" && sectorsDone() >= 4 && sectorsDone() < 8;
}
void EnrollController::setState(const QString &state)
{
if (m_state != state) {
m_state = state;
Q_EMIT stateChanged();
Q_EMIT sectorsChanged();
}
}
void EnrollController::setInstruction(const QString &text)
{
if (m_instruction != text) {
m_instruction = text;
Q_EMIT instructionChanged();
}
}
QString EnrollController::hintText(const QString &hint) const
{
if (hint == u"no-face") {
return i18n("Bring your face into the circle.");
} else if (hint == u"one-face") {
return i18n("Only one face, please.");
} else if (hint == u"closer") {
return i18n("Move a little closer.");
} else if (hint == u"light") {
return i18n("It is too dark. Turn on a light.");
} else if (hint == u"bright") {
return i18n("Too bright. Turn away from the light.");
} else if (hint == u"still") {
return i18n("Hold still for a moment.");
} else if (hint == u"straight" || hint == u"hold") {
return i18n("Look straight at the camera.");
} else if (hint == u"circle") {
return i18n("Move your head slowly to complete the circle.");
}
return {};
}
void EnrollController::start()
{
if (m_request) {
return;
}
for (bool &s : m_sectors) {
s = false;
}
m_error.clear();
m_frame = QImage();
Q_EMIT frameChanged();
setState(QStringLiteral("starting"));
setInstruction(i18n("Starting the camera…"));
m_request = new DaemonRequest({{QStringLiteral("cmd"), QStringLiteral("enroll")}, {QStringLiteral("name"), m_name}}, this);
connect(m_request, &DaemonRequest::event, this, &EnrollController::onEvent);
connect(m_request, &DaemonRequest::finished, this, &EnrollController::onFinished);
}
void EnrollController::finish()
{
if (m_request) {
m_request->send({{QStringLiteral("cmd"), QStringLiteral("finish")}});
}
}
void EnrollController::cancel()
{
if (m_request) {
m_request->abort();
m_request->deleteLater();
m_request = nullptr;
}
Q_EMIT completed(m_state == u"done");
}
void EnrollController::onEvent(const QJsonObject &e)
{
const QString what = e.value(u"event").toString();
if (what == u"authorizing") {
setState(QStringLiteral("authorizing"));
setInstruction(i18n("Confirm with your password to add a face."));
} else if (what == u"authorized") {
setState(QStringLiteral("starting"));
setInstruction(i18n("Starting the camera…"));
} else if (what == u"started") {
setState(QStringLiteral("center"));
setInstruction(hintText(QStringLiteral("straight")));
} else if (what == u"frame") {
const QByteArray jpeg = QByteArray::fromBase64(e.value(u"jpeg").toString().toLatin1());
QImage img;
if (img.loadFromData(jpeg, "JPG")) {
m_frame = img;
}
const QJsonObject f = e.value(u"face").toObject();
if (!f.isEmpty()) {
m_faceRect = QRectF(f.value(u"x").toDouble(), f.value(u"y").toDouble(), f.value(u"w").toDouble(), f.value(u"h").toDouble());
}
Q_EMIT frameChanged();
} else if (what == u"pose") {
m_faceVisible = e.value(u"face").toBool();
if (m_faceVisible) {
m_pose = QPointF(e.value(u"x").toDouble(), e.value(u"y").toDouble());
}
Q_EMIT poseChanged();
} else if (what == u"hint") {
const QString text = hintText(e.value(u"hint").toString());
if (!text.isEmpty()) {
setInstruction(text);
}
} else if (what == u"captured") {
const QString pose = e.value(u"pose").toString();
if (pose == u"center") {
setState(QStringLiteral("circle"));
setInstruction(hintText(QStringLiteral("circle")));
} else {
const int sector = e.value(u"sector").toInt(-1);
if (sector >= 0 && sector < 8) {
m_sectors[sector] = true;
Q_EMIT sectorsChanged();
}
}
}
}
void EnrollController::onFinished(const QJsonObject &result)
{
if (m_request) {
m_request->deleteLater();
m_request = nullptr;
}
if (result.value(u"ok").toBool()) {
for (bool &s : m_sectors) {
s = true;
}
setState(QStringLiteral("done"));
setInstruction(i18n("Face Unlock is set up."));
return;
}
const QString reason = result.value(u"reason").toString();
if (reason == u"cancelled") {
return;
}
if (reason == u"denied") {
m_error = i18n("A face can only be added with your password.");
} else if (reason == u"camera") {
m_error = i18n("The camera could not be used: %1", result.value(u"message").toString());
} else if (reason == u"models") {
m_error = i18n("The face recognition models are missing. Reinstall the package.");
} else if (reason == u"timeout") {
m_error = i18n("That took too long. Try again, in good light.");
} else if (reason == u"unreachable") {
m_error = i18n("The face unlock service is not running. Turn face unlock on first.");
} else if (reason == u"busy") {
m_error = i18n("The camera is busy with another scan. Try again in a moment.");
} else {
m_error = i18n("The face could not be added (%1).", reason);
}
setState(QStringLiteral("failed"));
setInstruction(m_error);
}
+112
View File
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The setup window's side of an enrollment: it starts one with the daemon,
// hands the camera pictures to the preview and turns the daemon's progress
// into what the ring and the text show.
#pragma once
#include <QImage>
#include <QObject>
#include <QPointF>
#include <QPointer>
#include <QRectF>
#include <QVariantList>
class DaemonRequest;
class EnrollController : public QObject
{
Q_OBJECT
// intro, authorizing, starting, center, circle, done, failed
Q_PROPERTY(QString state READ state NOTIFY stateChanged)
Q_PROPERTY(QString instruction READ instruction NOTIFY instructionChanged)
Q_PROPERTY(QString error READ error NOTIFY stateChanged)
Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)
// Eight booleans, clockwise from the top, as seen in the preview.
Q_PROPERTY(QVariantList sectors READ sectors NOTIFY sectorsChanged)
Q_PROPERTY(int sectorsDone READ sectorsDone NOTIFY sectorsChanged)
Q_PROPERTY(bool canFinish READ canFinish NOTIFY sectorsChanged)
// Where the head points, in the daemon's turn units (1.0 is a comfortable
// turn), screen directions: x to the right, y up.
Q_PROPERTY(QPointF pose READ pose NOTIFY poseChanged)
Q_PROPERTY(bool faceVisible READ faceVisible NOTIFY poseChanged)
// The face in the preview, as a share of the picture.
Q_PROPERTY(QRectF faceRect READ faceRect NOTIFY frameChanged)
Q_PROPERTY(bool hasFrame READ hasFrame NOTIFY frameChanged)
public:
explicit EnrollController(QObject *parent = nullptr);
QString state() const
{
return m_state;
}
QString instruction() const
{
return m_instruction;
}
QString error() const
{
return m_error;
}
QString name() const
{
return m_name;
}
void setName(const QString &name);
QVariantList sectors() const;
int sectorsDone() const;
bool canFinish() const;
QPointF pose() const
{
return m_pose;
}
bool faceVisible() const
{
return m_faceVisible;
}
QRectF faceRect() const
{
return m_faceRect;
}
bool hasFrame() const
{
return !m_frame.isNull();
}
QImage frame() const
{
return m_frame;
}
Q_INVOKABLE void start();
Q_INVOKABLE void finish();
Q_INVOKABLE void cancel();
Q_SIGNALS:
void stateChanged();
void instructionChanged();
void nameChanged();
void sectorsChanged();
void poseChanged();
void frameChanged();
// Emitted once, when the window can go: done or given up.
void completed(bool ok);
private:
void setState(const QString &state);
void setInstruction(const QString &text);
void onEvent(const QJsonObject &event);
void onFinished(const QJsonObject &result);
QString hintText(const QString &hint) const;
QPointer<DaemonRequest> m_request;
QString m_state = QStringLiteral("intro");
QString m_instruction;
QString m_error;
QString m_name;
bool m_sectors[8] = {};
QPointF m_pose;
bool m_faceVisible = false;
QRectF m_faceRect;
QImage m_frame;
};
+254
View File
@@ -0,0 +1,254 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "lockcontroller.h"
#include "bubblecontroller.h"
#include "daemonclient.h"
#include "userconfig.h"
#include <KIdleTime>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QJsonObject>
#include <QProcess>
namespace
{
// Locking with the keyboard is itself a key press, and the keys come back up
// a moment later. Input in this window after locking is not somebody coming
// back.
constexpr int GraceAfterLockMs = 1500;
// After a scan that did not get anybody in, the next one waits until the
// person has kept still for this long and then touched something again. That
// way typing the password does not start a scan with every key.
constexpr int CalmBeforeRetryMs = 2000;
// Show the tick before the screen goes: long enough to see, short enough
// not to feel like waiting.
constexpr int UnlockAfterSuccessMs = 450;
// A camera needs a moment after the machine wakes before it delivers frames.
constexpr int ScanAfterWakeMs = 1000;
} // namespace
LockController::LockController(BubbleController *bubble, UserConfig *config, QObject *parent)
: QObject(parent)
, m_bubble(bubble)
, m_config(config)
{
m_armTimer.setSingleShot(true);
connect(&m_armTimer, &QTimer::timeout, this, &LockController::arm);
QDBusConnection session = QDBusConnection::sessionBus();
session.connect(QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("/ScreenSaver"),
QStringLiteral("org.freedesktop.ScreenSaver"),
QStringLiteral("ActiveChanged"),
this,
SLOT(onActiveChanged(bool)));
QDBusConnection::systemBus().connect(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QStringLiteral("PrepareForSleep"),
this,
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).
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)
{
if (active == m_locked) {
return;
}
KIdleTime *idle = KIdleTime::instance();
if (!active) {
m_locked = false;
m_armTimer.stop();
if (m_idleId >= 0) {
idle->removeIdleTimeout(m_idleId);
m_idleId = -1;
}
idle->stopCatchingResumeEvent();
if (m_scan) {
m_scan->abort();
m_scan->deleteLater();
m_scan = nullptr;
}
// The tick of our own unlock is still playing; anything else goes.
if (m_bubble->phase() == u"scanning") {
m_bubble->dismiss();
}
// However it was unlocked, it was the right person: a lockout after
// failed scans ends here, the way a phone takes its code.
auto *done = new DaemonRequest({{QStringLiteral("cmd"), QStringLiteral("unlocked")}}, this);
connect(done, &DaemonRequest::finished, done, &QObject::deleteLater);
return;
}
if (!m_config->lockScreen()) {
return;
}
m_locked = true;
m_stopped = false;
m_lockedFor.start();
warmUp();
if (m_config->scanOnLock()) {
QTimer::singleShot(400, this, [this] {
startScan(QStringLiteral("lock"));
});
} else {
m_armTimer.start(GraceAfterLockMs);
}
}
void LockController::onPrepareForSleep(bool sleeping)
{
if (sleeping) {
if (m_scan) {
m_scan->abort();
m_scan->deleteLater();
m_scan = nullptr;
m_bubble->dismiss();
}
return;
}
// Waking up is somebody coming back, lid or no lid.
if (m_locked && !m_stopped && m_config->scanOnWake()) {
QTimer::singleShot(ScanAfterWakeMs, this, [this] {
startScan(QStringLiteral("resume"));
});
}
}
void LockController::arm()
{
if (!m_locked || m_stopped || !m_config->scanOnWake()) {
return;
}
KIdleTime::instance()->catchNextResumeEvent();
}
void LockController::onResume()
{
if (m_locked && !m_scan) {
startScan(QStringLiteral("wake"));
}
}
void LockController::warmUp()
{
// The daemon is started by its socket and loads the networks when it
// starts. Doing that now, while nobody is waiting, takes it off the first
// scan.
auto *hello = new DaemonRequest({{QStringLiteral("cmd"), QStringLiteral("hello")}}, this);
connect(hello, &DaemonRequest::finished, hello, &QObject::deleteLater);
}
void LockController::startScan(const QString &why)
{
if (!m_locked || m_scan || m_stopped) {
return;
}
qInfo("scanning (%s)", qPrintable(why));
m_bubble->scanStarted();
m_scan = new DaemonRequest({{QStringLiteral("cmd"), QStringLiteral("verify")}, {QStringLiteral("purpose"), QStringLiteral("unlock")}}, this);
connect(m_scan, &DaemonRequest::event, this, [this](const QJsonObject &e) {
const QString what = e.value(u"event").toString();
if (what == u"face") {
m_bubble->faceFound();
} else if (what == u"hint") {
m_bubble->hint(e.value(u"hint").toString());
}
});
connect(m_scan, &DaemonRequest::finished, this, &LockController::onScanFinished);
}
void LockController::onScanFinished(const QJsonObject &result)
{
if (m_scan) {
m_scan->deleteLater();
m_scan = nullptr;
}
if (!m_locked) {
return;
}
const QString reason = result.value(u"reason").toString();
if (result.value(u"ok").toBool()) {
m_bubble->succeeded();
QTimer::singleShot(UnlockAfterSuccessMs, this, &LockController::unlock);
return;
}
m_bubble->failed(reason, qint64(result.value(u"lockout").toDouble()));
if (reason == u"lockout" || result.contains(u"lockout") || reason == u"not-enrolled" || reason == u"models" || reason == u"denied"
|| reason == u"unreachable") {
// Nothing another scan could change before the next lock.
m_stopped = true;
return;
}
if (reason == u"busy") {
m_armTimer.start(GraceAfterLockMs);
return;
}
if (m_idleId < 0) {
m_idleId = KIdleTime::instance()->addIdleTimeout(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);
}
});
}
+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The lock screen.
//
// 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.
//
// 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.
#pragma once
#include <QElapsedTimer>
#include <QObject>
#include <QPointer>
#include <QTimer>
class BubbleController;
class DaemonRequest;
class UserConfig;
class LockController : public QObject
{
Q_OBJECT
public:
LockController(BubbleController *bubble, UserConfig *config, QObject *parent = nullptr);
bool locked() const
{
return m_locked;
}
private Q_SLOTS:
void onActiveChanged(bool active);
void onPrepareForSleep(bool sleeping);
private:
void arm();
void onResume();
void startScan(const QString &why);
void onScanFinished(const QJsonObject &result);
void unlock();
void warmUp();
BubbleController *m_bubble;
UserConfig *m_config;
bool m_locked = false;
bool m_stopped = false;
QElapsedTimer m_lockedFor;
QPointer<DaemonRequest> m_scan;
QTimer m_armTimer;
int m_idleId = -1;
};
+126
View File
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// plasma-face-unlock-agent: the part in the user's session.
//
// (no arguments) stay in the background: unlock the lock screen by face,
// and show the bubble for every scan
// --enroll open the window that sets up a face
// --demo play the bubble's animations once, for trying out a
// style (--bubble-style minimal) and for screenshots
#include "agentsocket.h"
#include "bubblecontroller.h"
#include "bubblewindow.h"
#include "enrollcontroller.h"
#include "lockcontroller.h"
#include "userconfig.h"
#include "buildconfig.h"
#include <KLocalizedQmlContext>
#include <KLocalizedString>
#include <QCommandLineParser>
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlEngine>
#include <QTimer>
#include <unistd.h>
namespace
{
int runEnroll(QGuiApplication &app, const QString &name)
{
app.setQuitOnLastWindowClosed(true);
EnrollController controller;
controller.setName(name);
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
engine.setInitialProperties({{QStringLiteral("controller"), QVariant::fromValue(&controller)}});
engine.loadFromModule(QStringLiteral("PlasmaFaceUnlock"), QStringLiteral("Enroll"));
if (engine.rootObjects().isEmpty()) {
return 2;
}
int code = 1;
QObject::connect(&controller, &EnrollController::completed, &app, [&code](bool ok) {
code = ok ? 0 : 1;
QCoreApplication::quit();
});
app.exec();
return controller.state() == u"done" ? 0 : code;
}
// The whole life of a bubble, twice: a face that is recognised after a blink,
// then one that is not.
void scheduleDemo(BubbleController *bubble)
{
const QList<std::pair<int, std::function<void()>>> steps = {
{300, [bubble] { bubble->scanStarted(); }},
{900, [bubble] { bubble->faceFound(); }},
{1900, [bubble] { bubble->hint(QStringLiteral("blink")); }},
{3000, [bubble] { bubble->succeeded(); }},
{5600, [bubble] { bubble->scanStarted(); }},
{6200, [bubble] { bubble->faceFound(); }},
{7800, [bubble] { bubble->failed(QStringLiteral("mismatch")); }},
{11000, [] { QCoreApplication::quit(); }},
};
for (const auto &[at, what] : steps) {
QTimer::singleShot(at, bubble, what);
}
}
} // namespace
int main(int argc, char **argv)
{
QGuiApplication app(argc, argv);
app.setApplicationName(QStringLiteral("plasma-face-unlock-agent"));
app.setApplicationVersion(QStringLiteral(PFU_VERSION));
app.setDesktopFileName(QStringLiteral("io.github.loonixtools.plasma-face-unlock-agent"));
KLocalizedString::setApplicationDomain(PFU_NAME);
QCommandLineParser parser;
parser.setApplicationDescription(i18n("Face unlock for KDE Plasma"));
parser.addHelpOption();
parser.addVersionOption();
const QCommandLineOption enrollOpt(QStringLiteral("enroll"), i18n("Set up a face."));
const QCommandLineOption nameOpt(QStringLiteral("name"), i18n("What to call the new face."), QStringLiteral("name"));
const QCommandLineOption demoOpt(QStringLiteral("demo"), i18n("Play the bubble's animations once."));
// Not "--style": QGuiApplication takes that one for itself.
const QCommandLineOption styleOpt(QStringLiteral("bubble-style"), i18n("Bubble style for the demo: full or minimal."), QStringLiteral("style"));
parser.addOptions({enrollOpt, nameOpt, demoOpt, styleOpt});
parser.process(app);
if (parser.isSet(enrollOpt)) {
QString name = parser.value(nameOpt);
if (name.isEmpty()) {
name = qEnvironmentVariable("USER");
}
return runEnroll(app, name);
}
app.setQuitOnLastWindowClosed(false);
QQmlEngine engine;
KLocalization::setupLocalizedContext(&engine);
UserConfig config;
if (parser.isSet(styleOpt)) {
config.overrideStyle(parser.value(styleOpt));
}
BubbleController bubble(&config);
BubbleWindow window(&engine, &bubble);
if (parser.isSet(demoOpt)) {
scheduleDemo(&bubble);
return app.exec();
}
AgentSocket socket;
socket.listen();
QObject::connect(&socket, &AgentSocket::scanEvent, &bubble, &BubbleController::daemonEvent);
LockController lock(&bubble, &config);
return app.exec();
}
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "previewitem.h"
#include "enrollcontroller.h"
#include <QPainter>
#include <QPainterPath>
PreviewItem::PreviewItem(QQuickItem *parent)
: QQuickPaintedItem(parent)
{
setAntialiasing(true);
}
QObject *PreviewItem::controller() const
{
return m_controller;
}
void PreviewItem::setController(QObject *controller)
{
auto *c = qobject_cast<EnrollController *>(controller);
if (c == m_controller) {
return;
}
if (m_controller) {
disconnect(m_controller, nullptr, this, nullptr);
}
m_controller = c;
if (m_controller) {
connect(m_controller, &EnrollController::frameChanged, this, &PreviewItem::onFrame);
}
Q_EMIT controllerChanged();
}
void PreviewItem::onFrame()
{
if (!m_controller) {
return;
}
m_frame = m_controller->frame();
const QRectF face = m_controller->faceRect();
if (face.isValid() && !m_frame.isNull()) {
// The face fills a little over half of the circle, whatever the
// distance, so a small face does not end up as a dot in the middle.
const qreal aspect = qreal(m_frame.width()) / m_frame.height();
const qreal faceSide = std::max(face.width() * aspect, face.height());
const qreal targetScale = std::clamp(0.55 / std::max(0.05, faceSide), 1.0, 2.2);
const QPointF target = face.center();
m_centre += (target - m_centre) * 0.25;
m_scale += (targetScale - m_scale) * 0.2;
}
update();
}
void PreviewItem::paint(QPainter *painter)
{
const QRectF bounds = boundingRect();
QPainterPath circle;
circle.addEllipse(bounds);
painter->setRenderHint(QPainter::Antialiasing);
painter->setRenderHint(QPainter::SmoothPixmapTransform);
painter->setClipPath(circle);
painter->fillRect(bounds, QColor(0x1e, 0x1e, 0x1e));
if (m_frame.isNull()) {
return;
}
// Cover the circle with the picture: its shorter side spans the circle,
// zoomed by m_scale and centred on the face.
const qreal fw = m_frame.width();
const qreal fh = m_frame.height();
const qreal side = std::min(fw, fh) / m_scale;
QRectF source(m_centre.x() * fw - side / 2, m_centre.y() * fh - side / 2, side, side);
if (source.left() < 0) {
source.moveLeft(0);
}
if (source.top() < 0) {
source.moveTop(0);
}
if (source.right() > fw) {
source.moveRight(fw);
}
if (source.bottom() > fh) {
source.moveBottom(fh);
}
painter->drawImage(bounds, m_frame, source);
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The camera picture in the setup window, cut to a circle around the face.
// The daemon already mirrors it, the way a mirror would show it.
#pragma once
#include <QImage>
#include <QPointer>
#include <QQuickPaintedItem>
#include <QtQml/qqmlregistration.h>
class EnrollController;
class PreviewItem : public QQuickPaintedItem
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QObject *controller READ controller WRITE setController NOTIFY controllerChanged)
public:
explicit PreviewItem(QQuickItem *parent = nullptr);
QObject *controller() const;
void setController(QObject *controller);
void paint(QPainter *painter) override;
Q_SIGNALS:
void controllerChanged();
private:
void onFrame();
QPointer<EnrollController> m_controller;
QImage m_frame;
// Where the circle is centred in the picture, eased towards the face so
// the crop does not jump with every detection.
QPointF m_centre{0.5, 0.5};
qreal m_scale = 1.0;
};
+210
View File
@@ -0,0 +1,210 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The bubble: a black island at the top of the screen that slides down,
// opens up, shows the face while it looks, and closes again.
//
// The choreography is Glance's: entering, the island slides in first and grows
// a moment later; leaving, it shrinks first and slides away after. Growing is
// a spring that overshoots a little, shrinking does not. While it scans the
// content breathes, so it reads as looking rather than stuck.
//
// "full" is the open island with the face in it. "minimal" is a small pill
// with a lock on one side and the face on the other.
import QtQuick
import QtQuick.Effects
Item {
id: root
required property var bubble
width: 420
height: 300
readonly property string phase: bubble ? bubble.phase : "hidden"
readonly property bool minimal: bubble && bubble.style === "minimal"
readonly property bool wantOpen: phase !== "hidden"
property bool positioned: false
property bool expanded: false
function choreograph() {
if (wantOpen) {
slideOut.stop()
closeDone.stop()
positioned = true
if (!expanded) {
expandLater.restart()
}
} else {
expandLater.stop()
expanded = false
slideOut.restart()
}
}
onWantOpenChanged: choreograph()
Component.onCompleted: choreograph()
Timer {
id: expandLater
interval: Theme.expandDelay
onTriggered: root.expanded = true
}
Timer {
id: slideOut
interval: Theme.slideOutDelay
onTriggered: {
root.positioned = false
closeDone.restart()
}
}
Timer {
id: closeDone
interval: Theme.slideDuration + 60
onTriggered: if (root.bubble) root.bubble.closed()
}
// What the face shows, from the phase.
readonly property string glyphMode: phase === "success" ? "success"
: phase === "failure" ? "failure"
: phase === "lockout" ? "lockout"
: phase === "scanning" ? (bubble.faceSeen ? "tracking" : "scanning")
: "idle"
// -- the breathing while it scans
property real pulse: 0
SequentialAnimation on pulse {
id: pulseAnimation
running: root.phase === "scanning" && root.expanded
loops: Animation.Infinite
PauseAnimation { duration: 600 }
NumberAnimation { from: 0; to: 1; duration: 400; easing.type: Easing.InOutQuad }
PauseAnimation { duration: 50 }
NumberAnimation { from: 1; to: 0; duration: 400; easing.type: Easing.InOutQuad }
PauseAnimation { duration: 50 }
onRunningChanged: if (!running) settle.restart()
}
NumberAnimation {
id: settle
target: root
property: "pulse"
to: 0
duration: 200
easing.type: Easing.OutQuad
}
// -- the minimal pill shakes as a whole; the full island shakes its face
property real shake: 0
onPhaseChanged: if (phase === "failure" && minimal) pillShake.restart()
SequentialAnimation {
id: pillShake
NumberAnimation { target: root; property: "shake"; to: -10; duration: 55; easing.type: Easing.OutQuad }
NumberAnimation { target: root; property: "shake"; to: 9; duration: 70 }
NumberAnimation { target: root; property: "shake"; to: -6; duration: 65 }
NumberAnimation { target: root; property: "shake"; to: 4; duration: 60 }
NumberAnimation { target: root; property: "shake"; to: 0; duration: 55; easing.type: Easing.OutQuad }
}
Item {
id: island
readonly property real targetWidth: root.expanded ? (root.minimal ? Theme.minimalWidth : Theme.openWidth) : Theme.closedWidth
readonly property real targetHeight: root.expanded ? (root.minimal ? Theme.minimalHeight : Theme.openHeight) : Theme.closedHeight
width: targetWidth
height: targetHeight
// Growing overshoots a little; shrinking settles without bouncing.
Behavior on width { SpringAnimation { spring: root.expanded ? 3.4 : 6; damping: root.expanded ? 0.28 : 0.9; epsilon: 0.25 } }
Behavior on height { SpringAnimation { spring: root.expanded ? 3.4 : 6; damping: root.expanded ? 0.28 : 0.9; epsilon: 0.25 } }
x: (root.width - width) / 2 + root.shake
y: root.positioned ? Theme.topGap : -height - 30
Behavior on y { NumberAnimation { duration: Theme.slideDuration; easing.type: Easing.OutCubic } }
Rectangle {
id: shape
anchors.fill: parent
color: Theme.panel
radius: root.expanded && !root.minimal ? Math.min(Theme.openRadius, height / 2) : height / 2
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: "#000000"
shadowOpacity: root.expanded ? 0.35 : 0
shadowBlur: 0.7
shadowVerticalOffset: 3
Behavior on shadowOpacity { NumberAnimation { duration: 200 } }
}
}
// -- full: the face, and a line of text under it
Item {
id: full
anchors.fill: parent
visible: !root.minimal
opacity: root.expanded ? 1 - 0.35 * root.pulse : 0
scale: root.expanded ? 1 - 0.03 * root.pulse : 0.3
Behavior on opacity { enabled: !pulseAnimation.running; NumberAnimation { duration: 220 } }
Behavior on scale { enabled: !pulseAnimation.running; NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
readonly property bool hasMessage: root.bubble && root.bubble.message.length > 0
FaceGlyph {
id: glyph
width: 100
height: 100
anchors.horizontalCenter: parent.horizontalCenter
y: full.hasMessage ? 28 : 40
Behavior on y { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
mode: root.glyphMode
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 20
width: parent.width - 32
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: root.bubble ? root.bubble.message : ""
color: root.phase === "failure" || root.phase === "lockout" ? Theme.textDetail : Theme.textSecondary
font.pixelSize: 13
font.weight: Font.Medium
opacity: full.hasMessage ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 200 } }
}
}
// -- minimal: [ lock ] ... [ face ]
Item {
id: small
anchors.fill: parent
visible: root.minimal
opacity: root.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 200 } }
LockGlyph {
width: 18
height: 18
anchors.verticalCenter: parent.verticalCenter
x: 16
open: root.phase === "success"
color: root.phase === "failure" || root.phase === "lockout" ? Theme.failure : Theme.textPrimary
}
FaceGlyph {
width: 24
height: 24
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: 14
lineWidth: 2.4
mode: root.glyphMode === "lockout" ? "failure" : root.glyphMode
opacity: 1 - 0.35 * root.pulse
scale: 1 - 0.03 * root.pulse
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// A tick that draws itself: the short stroke first, then the long one.
import QtQuick
import QtQuick.Shapes
Item {
id: root
property color color: Theme.success
property real lineWidth: width * 0.07
// 0: nothing, 1: the whole tick.
property real progress: 0
readonly property real u: width / 100
readonly property point a: Qt.point(28 * u, 52 * u)
readonly property point b: Qt.point(43 * u, 67 * u)
readonly property point c: Qt.point(73 * u, 35 * u)
// The short stroke is about a third of the length.
readonly property real split: 0.33
function lerp(p, q, t) {
return Qt.point(p.x + (q.x - p.x) * t, p.y + (q.y - p.y) * t)
}
Shape {
anchors.fill: parent
visible: root.progress > 0.001
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: root.color
strokeWidth: root.lineWidth
fillColor: "transparent"
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
PathPolyline {
path: root.progress <= root.split
? [root.a, root.lerp(root.a, root.b, root.progress / root.split)]
: [root.a, root.b, root.lerp(root.b, root.c, (root.progress - root.split) / (1 - root.split))]
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Setting up a face: a short introduction, the password (polkit's own
// dialog), then the camera in a circle with the ring of ticks around it, and
// a tick at the end. Laid out like the phone's setup and Glance's onboarding.
import QtQuick
import QtQuick.Window
import PlasmaFaceUnlock
Window {
id: window
required property var controller
width: 520
height: 680
minimumWidth: 460
minimumHeight: 620
visible: true
color: Theme.panel
title: i18n("Set up Face Unlock")
readonly property string step: controller.state
readonly property bool scanning: step === "center" || step === "circle"
readonly property bool done: step === "done"
onClosing: controller.cancel()
Item {
id: stage
anchors.horizontalCenter: parent.horizontalCenter
y: 40
width: 360
height: 360
// Before the camera, and when something went wrong: the face, alive.
FaceGlyph {
anchors.centerIn: parent
width: 150
height: 150
mode: window.step === "failed" ? "failure" : "scanning"
opacity: window.scanning || window.done ? 0 : 1
scale: window.scanning || window.done ? 0.8 : 1
Behavior on opacity { NumberAnimation { duration: 250 } }
Behavior on scale { NumberAnimation { duration: 300; easing.type: Easing.OutCubic } }
}
// The camera, the ring, and the tick at the end.
Item {
anchors.fill: parent
opacity: window.scanning || window.done ? 1 : 0
scale: window.scanning || window.done ? 1 : 0.9
Behavior on opacity { NumberAnimation { duration: 300 } }
Behavior on scale { NumberAnimation { duration: 350; easing.type: Easing.OutCubic } }
PreviewItem {
id: preview
anchors.centerIn: parent
width: 264
height: 264
controller: window.controller
opacity: window.done ? 0.25 : 1
Behavior on opacity { NumberAnimation { duration: 400 } }
}
TickRing {
anchors.fill: parent
sectors: window.controller.sectors
pose: window.controller.pose
tracking: window.step === "circle" && window.controller.faceVisible
complete: window.done
}
Checkmark {
anchors.centerIn: parent
width: 150
height: 150
color: Theme.accent
progress: window.done ? 1 : 0
Behavior on progress { SequentialAnimation {
PauseAnimation { duration: 350 }
NumberAnimation { duration: 450; easing.type: Easing.OutCubic }
} }
}
}
}
Column {
id: texts
anchors.top: stage.bottom
anchors.topMargin: 26
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 80
spacing: 12
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: Theme.textPrimary
font.pixelSize: 26
font.weight: Font.Bold
visible: text.length > 0
text: window.step === "intro" ? i18n("Face Unlock")
: window.step === "authorizing" ? i18n("Confirm it is you")
: window.done ? i18n("You are all set")
: window.step === "failed" ? i18n("Setup did not finish")
: ""
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: window.scanning ? Theme.textPrimary : Theme.textSecondary
font.pixelSize: window.scanning ? 15 : 13
font.weight: Font.Medium
lineHeight: 1.15
text: window.step === "intro"
? i18n("Look at the camera, then move your head slowly in a circle. Your face is turned into numbers on this computer and never stored as a picture.")
: window.done
? i18n("Lock the screen and look at it to try it out. You can add a second look, with glasses for example, from the menu.")
: window.controller.instruction
}
// A name, so more than one face can be told apart in the menu.
Rectangle {
visible: window.step === "intro"
anchors.horizontalCenter: parent.horizontalCenter
width: 260
height: 38
radius: 10
color: Theme.surfaceRaised
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
verticalAlignment: TextInput.AlignVCenter
color: Theme.textPrimary
selectionColor: Theme.accent
font.pixelSize: 13
clip: true
maximumLength: 64
focus: true
text: window.controller.name
onTextEdited: window.controller.name = text
onAccepted: window.controller.start()
}
Text {
anchors.fill: nameInput
verticalAlignment: Text.AlignVCenter
color: Theme.textSecondary
font.pixelSize: 13
text: i18n("Name (optional)")
visible: nameInput.text.length === 0
}
}
}
Row {
anchors.bottom: parent.bottom
anchors.bottomMargin: 32
anchors.horizontalCenter: parent.horizontalCenter
spacing: 12
PillButton {
visible: window.step !== "done"
primary: false
text: i18n("Cancel")
onClicked: window.controller.cancel()
}
PillButton {
visible: window.controller.canFinish
primary: false
text: i18n("Finish now")
onClicked: window.controller.finish()
}
PillButton {
visible: window.step === "intro" || window.step === "failed"
text: window.step === "failed" ? i18n("Try again") : i18n("Get started")
onClicked: window.controller.start()
}
PillButton {
visible: window.done
text: i18n("Done")
onClicked: window.controller.cancel()
}
}
}
+183
View File
@@ -0,0 +1,183 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The face: four corner brackets around two eyes, a nose and a smile, the
// shape everybody knows from a phone. Drawn with shapes rather than played
// from a video, so it is sharp at any size and every part can move on its own.
//
// idle still
// scanning the face looks around inside the brackets, the brackets breathe
// tracking a face is in view: it looks straight ahead, brackets close in
// success the face gives way to a tick, the brackets turn green
// failure it shakes its head and the smile goes flat
// lockout a lock instead of a face
import QtQuick
import QtQuick.Shapes
Item {
id: root
property string mode: "idle"
property color color: Theme.textPrimary
property real lineWidth: width * 0.055
readonly property real u: width / 100
readonly property bool looking: mode === "scanning"
readonly property color bracketColor: mode === "success" ? Theme.success
: mode === "failure" ? Theme.failure
: root.color
// -- where the face looks, while it looks around
property real lookAngle: 0
NumberAnimation on lookAngle {
running: root.looking
from: 0
to: 2 * Math.PI
duration: 2400
loops: Animation.Infinite
}
property real lookAmount: root.looking ? 1 : 0
Behavior on lookAmount { NumberAnimation { duration: 300; easing.type: Easing.InOutQuad } }
readonly property real lookX: 3.5 * u * Math.cos(lookAngle) * lookAmount
readonly property real lookY: 2.2 * u * Math.sin(lookAngle) * lookAmount
// -- the smile, flat on failure
property real smile: mode === "failure" ? 0 : 1
Behavior on smile { NumberAnimation { duration: 220; easing.type: Easing.OutQuad } }
// -- the brackets breathe while scanning and close in on a face
property real breathe: 0
SequentialAnimation on breathe {
running: root.looking
loops: Animation.Infinite
NumberAnimation { from: 0; to: 1; duration: 700; easing.type: Easing.InOutSine }
NumberAnimation { from: 1; to: 0; duration: 700; easing.type: Easing.InOutSine }
onRunningChanged: if (!running) root.breathe = 0
}
readonly property real bracketScale: mode === "tracking" ? 0.9
: mode === "success" ? 1.04
: 1 - 0.04 * breathe
// -- a shake of the head
property real shake: 0
onModeChanged: if (mode === "failure") shakeAnimation.restart()
SequentialAnimation {
id: shakeAnimation
NumberAnimation { target: root; property: "shake"; to: -9; duration: 55; easing.type: Easing.OutQuad }
NumberAnimation { target: root; property: "shake"; to: 8; duration: 70; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shake"; to: -6; duration: 65; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shake"; to: 4; duration: 60; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shake"; to: -2; duration: 55; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shake"; to: 0; duration: 50; easing.type: Easing.OutQuad }
}
Item {
id: glyph
anchors.fill: parent
transform: Translate { x: root.shake * root.u }
// Brackets
Shape {
id: brackets
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
scale: root.bracketScale
Behavior on scale { NumberAnimation { duration: 260; easing.type: Easing.OutBack } }
component Bracket: ShapePath {
strokeColor: root.bracketColor
strokeWidth: root.lineWidth
fillColor: "transparent"
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
Behavior on strokeColor { ColorAnimation { duration: 220 } }
}
Bracket {
startX: 6 * root.u; startY: 30 * root.u
PathLine { x: 6 * root.u; y: 19 * root.u }
PathQuad { x: 19 * root.u; y: 6 * root.u; controlX: 6 * root.u; controlY: 6 * root.u }
PathLine { x: 30 * root.u; y: 6 * root.u }
}
Bracket {
startX: 70 * root.u; startY: 6 * root.u
PathLine { x: 81 * root.u; y: 6 * root.u }
PathQuad { x: 94 * root.u; y: 19 * root.u; controlX: 94 * root.u; controlY: 6 * root.u }
PathLine { x: 94 * root.u; y: 30 * root.u }
}
Bracket {
startX: 94 * root.u; startY: 70 * root.u
PathLine { x: 94 * root.u; y: 81 * root.u }
PathQuad { x: 81 * root.u; y: 94 * root.u; controlX: 94 * root.u; controlY: 94 * root.u }
PathLine { x: 70 * root.u; y: 94 * root.u }
}
Bracket {
startX: 30 * root.u; startY: 94 * root.u
PathLine { x: 19 * root.u; y: 94 * root.u }
PathQuad { x: 6 * root.u; y: 81 * root.u; controlX: 6 * root.u; controlY: 94 * root.u }
PathLine { x: 6 * root.u; y: 70 * root.u }
}
}
// The face itself
Shape {
id: face
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
opacity: root.mode === "success" || root.mode === "lockout" ? 0 : 1
scale: root.mode === "success" ? 0.6 : 1
Behavior on opacity { NumberAnimation { duration: 180 } }
Behavior on scale { NumberAnimation { duration: 220; easing.type: Easing.InQuad } }
transform: Translate { x: root.lookX; y: root.lookY }
component Feature: ShapePath {
strokeColor: root.mode === "failure" ? Theme.failure : root.color
strokeWidth: root.lineWidth
fillColor: "transparent"
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
Behavior on strokeColor { ColorAnimation { duration: 220 } }
}
// Eyes
Feature {
startX: 34 * root.u; startY: 36 * root.u
PathLine { x: 34 * root.u; y: 45 * root.u }
}
Feature {
startX: 66 * root.u; startY: 36 * root.u
PathLine { x: 66 * root.u; y: 45 * root.u }
}
// Nose, with its little hook
Feature {
startX: 50 * root.u; startY: 37 * root.u
PathLine { x: 50 * root.u; y: 56 * root.u }
PathQuad { x: 45 * root.u; y: 61 * root.u; controlX: 50 * root.u; controlY: 61 * root.u }
}
// Mouth
Feature {
startX: 35 * root.u; startY: 70 * root.u
PathQuad { x: 65 * root.u; y: 70 * root.u; controlX: 50 * root.u; controlY: (70 + 11 * root.smile) * root.u }
}
}
Checkmark {
anchors.fill: parent
color: Theme.success
lineWidth: root.lineWidth * 1.15
progress: root.mode === "success" ? 1 : 0
Behavior on progress { NumberAnimation { duration: 380; easing.type: Easing.OutCubic } }
}
LockGlyph {
anchors.centerIn: parent
width: parent.width * 0.42
height: width
color: root.color
opacity: root.mode === "lockout" ? 1 : 0
scale: root.mode === "lockout" ? 1 : 0.7
Behavior on opacity { NumberAnimation { duration: 200 } }
Behavior on scale { NumberAnimation { duration: 260; easing.type: Easing.OutBack } }
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// A padlock whose shackle lifts and swings open.
import QtQuick
import QtQuick.Shapes
Item {
id: root
property color color: Theme.textPrimary
property bool open: false
readonly property real u: width / 100
// Body
Rectangle {
x: 14 * root.u
y: 46 * root.u
width: 72 * root.u
height: 50 * root.u
radius: 12 * root.u
color: root.color
}
// Shackle: a U standing on the body. Opening lifts it and swings it about
// its right leg.
Item {
id: shackle
x: 26 * root.u
y: 4 * root.u
width: 48 * root.u
height: 52 * root.u
transformOrigin: Item.BottomRight
property real lift: root.open ? 7 * root.u : 0
Behavior on lift { NumberAnimation { duration: 260; easing.type: Easing.OutBack } }
rotation: root.open ? -28 : 0
Behavior on rotation { NumberAnimation { duration: 320; easing.type: Easing.OutBack } }
transform: Translate { y: -shackle.lift }
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: root.color
strokeWidth: 11 * root.u
fillColor: "transparent"
capStyle: ShapePath.FlatCap
startX: 5.5 * root.u
startY: shackle.height
PathLine { x: 5.5 * root.u; y: 24 * root.u }
PathArc {
x: shackle.width - 5.5 * root.u
y: 24 * root.u
radiusX: (shackle.width - 11 * root.u) / 2
radiusY: radiusX
}
PathLine { x: shackle.width - 5.5 * root.u; y: shackle.height }
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
Rectangle {
id: root
property string text
property bool primary: true
signal clicked
implicitWidth: Math.max(140, label.implicitWidth + 44)
implicitHeight: 38
radius: height / 2
color: primary ? (area.pressed ? Qt.darker(Theme.accent, 1.2) : area.containsMouse ? Qt.lighter(Theme.accent, 1.1) : Theme.accent)
: (area.pressed ? Theme.surface : area.containsMouse ? Qt.lighter(Theme.surfaceRaised, 1.2) : Theme.surfaceRaised)
opacity: enabled ? 1 : 0.4
Behavior on color { ColorAnimation { duration: 120 } }
activeFocusOnTab: true
Keys.onReturnPressed: root.clicked()
Keys.onSpacePressed: root.clicked()
Text {
id: label
anchors.centerIn: parent
text: root.text
color: Theme.textPrimary
font.pixelSize: 13
font.weight: Font.Medium
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Colours and sizes, in one place. The palette and the island's proportions
// follow Glance's (github.com/jonnyoo/glance): black panel, white glyph, one
// blue for anything that is done.
pragma Singleton
import QtQuick
QtObject {
readonly property color accent: "#3499FF"
readonly property color accentPale: "#CFE7FF"
readonly property color accentBright: "#7FC2FF"
readonly property color success: "#30D158"
readonly property color failure: "#FF453A"
readonly property color panel: "#000000"
readonly property color surface: "#1E1E1E"
readonly property color surfaceRaised: "#323232"
readonly property color placeholder: "#2F2F2F"
readonly property color textPrimary: "#FFFFFF"
readonly property color textSecondary: "#949494"
readonly property color textDetail: "#BDBDBD"
// The island, closed and open. It hangs this far below the top edge.
readonly property int closedWidth: 80
readonly property int closedHeight: 24
readonly property int openWidth: 180
readonly property int openHeight: 180
readonly property int openRadius: 48
readonly property int minimalWidth: 150
readonly property int minimalHeight: 40
readonly property int topGap: 6
// Enter: slide down, then grow. Leave: shrink, then slide up.
readonly property int slideDuration: 250
readonly property int expandDelay: 160
readonly property int slideOutDelay: 180
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The ring of ticks around the camera picture during setup, as on the phone.
// 80 ticks in eight sectors; a sector lights up once the head has pointed
// that way long enough. A few ticks also follow where the head points right
// now, so it is clear which way is still missing. When everything is done the
// ticks give way to one closed ring.
import QtQuick
Item {
id: root
// Eight booleans, clockwise from the top.
property var sectors: [false, false, false, false, false, false, false, false]
// Where the head points, x to the right and y up, 1.0 a comfortable turn.
property point pose: Qt.point(0, 0)
property bool tracking: false
property bool complete: false
// Grows the centre ticks for a moment when the straight-ahead samples
// have been taken.
property bool centreDone: false
readonly property int tickCount: 80
readonly property real innerRadius: width / 2 - 22
readonly property real headAngle: {
const a = Math.atan2(pose.x, pose.y) * 180 / Math.PI
return a < 0 ? a + 360 : a
}
readonly property real headReach: Math.min(1, Math.sqrt(pose.x * pose.x + pose.y * pose.y))
function lit(index) {
if (complete) {
return true
}
const sector = Math.round(index * 360 / tickCount / 45) % 8
return sectors[sector] === true
}
// How much a tick lights up for the head pointing its way.
function intensity(index) {
if (!tracking || complete || lit(index)) {
return 0
}
let delta = Math.abs(index * 360 / tickCount - headAngle)
if (delta > 180) {
delta = 360 - delta
}
const halfSpan = 6 * 360 / tickCount
return headReach * Math.max(0, 1 - delta / halfSpan)
}
Repeater {
model: root.tickCount
Item {
id: tick
required property int index
readonly property bool isLit: root.lit(index)
readonly property real glow: root.intensity(index)
width: root.width
height: root.height
rotation: index * 360 / root.tickCount
Rectangle {
width: 3
// Grows outwards: the inner tip stays on the ring.
height: tick.isLit ? 18 : 10 + 8 * tick.glow
radius: 1.5
x: (parent.width - width) / 2
y: parent.height / 2 - root.innerRadius - height
antialiasing: true
color: tick.isLit ? Theme.accent : Qt.rgba(1 - 0.8 * tick.glow * (1 - Theme.accent.r) , 1 - 0.8 * tick.glow * (1 - Theme.accent.g), 1 - 0.8 * tick.glow * (1 - Theme.accent.b), 0.28 + 0.72 * tick.glow)
opacity: root.complete ? 0 : 1
Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 250 } }
Behavior on opacity { SequentialAnimation {
PauseAnimation { duration: tick.index * 4 }
NumberAnimation { duration: 400; easing.type: Easing.InOutQuad }
} }
}
}
}
// The closed ring that replaces the ticks at the end.
Rectangle {
anchors.centerIn: parent
width: 2 * root.innerRadius + 26
height: width
radius: width / 2
color: "transparent"
border.color: Theme.accent
border.width: 5
opacity: root.complete ? 1 : 0
scale: root.complete ? 1 : 0.92
Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on scale { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "userconfig.h"
#include "keyvalue.h"
#include <QDir>
#include <QFileInfo>
#include <QStandardPaths>
UserConfig::UserConfig(QObject *parent)
: QObject(parent)
{
// The menu replaces the file instead of editing it, which a watch on the
// file alone would lose track of. The directory sees the new one arrive.
const QString dir = QFileInfo(path()).absolutePath();
QDir().mkpath(dir);
m_watcher.addPath(dir);
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &UserConfig::load);
connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, &UserConfig::load);
load();
}
QString UserConfig::path()
{
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + QStringLiteral("/plasma-face-unlock/config");
}
void UserConfig::load()
{
const KeyValueFile kv = KeyValueFile::load(path());
m_lockScreen = kv.boolean(QStringLiteral("LockScreen"), true);
m_scanOnWake = kv.boolean(QStringLiteral("ScanOnWake"), true);
m_scanOnLock = kv.boolean(QStringLiteral("ScanOnLock"), false);
m_bubble = kv.boolean(QStringLiteral("Bubble"), true);
m_bubbleStyle = kv.value(QStringLiteral("BubbleStyle"), QStringLiteral("full")) == u"minimal" ? QStringLiteral("minimal") : QStringLiteral("full");
m_bubbleForPrompts = kv.boolean(QStringLiteral("BubbleForPrompts"), true);
if (QFileInfo::exists(path()) && !m_watcher.files().contains(path())) {
m_watcher.addPath(path());
}
Q_EMIT changed();
}
+74
View File
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// ~/.config/plasma-face-unlock/config: what this user wants from the agent.
// Written by the menu, read here, and read again whenever it changes.
#pragma once
#include <QFileSystemWatcher>
#include <QObject>
class UserConfig : public QObject
{
Q_OBJECT
Q_PROPERTY(bool bubble READ bubble NOTIFY changed)
Q_PROPERTY(QString bubbleStyle READ bubbleStyle NOTIFY changed)
public:
explicit UserConfig(QObject *parent = nullptr);
// Unlock the lock screen by face.
bool lockScreen() const
{
return m_lockScreen;
}
// Scan when somebody comes back to a locked screen (a key, the mouse,
// opening the lid).
bool scanOnWake() const
{
return m_scanOnWake;
}
// Scan right as the screen locks. Off by default: somebody who locks
// their screen on purpose is usually still sitting in front of it.
bool scanOnLock() const
{
return m_scanOnLock;
}
bool bubble() const
{
return m_bubble;
}
// "full" (the island with the face) or "minimal" (a small pill with a
// lock).
QString bubbleStyle() const
{
return m_styleOverride.isEmpty() ? m_bubbleStyle : m_styleOverride;
}
// For --demo --style: try a style without writing it down.
void overrideStyle(const QString &style)
{
m_styleOverride = style == u"minimal" ? QStringLiteral("minimal") : QStringLiteral("full");
Q_EMIT changed();
}
// Show the bubble for sudo and admin prompts, not only the lock screen.
bool bubbleForPrompts() const
{
return m_bubbleForPrompts;
}
static QString path();
Q_SIGNALS:
void changed();
private:
void load();
QFileSystemWatcher m_watcher;
bool m_lockScreen = true;
bool m_scanOnWake = true;
bool m_scanOnLock = false;
bool m_bubble = true;
QString m_bubbleStyle = QStringLiteral("full");
bool m_bubbleForPrompts = true;
QString m_styleOverride;
};