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;
};
+285
View File
@@ -0,0 +1,285 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "camera.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <fcntl.h>
#include <linux/videodev2.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <algorithm>
#include <thread>
using namespace std::chrono;
namespace
{
constexpr int Width = 640;
constexpr int Height = 480;
// Pictures stand in for a camera at this rate, each held for a while so the
// checks see a still face the way they would see a person holding still.
constexpr double ImageFrameMs = 1000.0 / 15.0;
constexpr int ImageRepeat = 12;
QString sysName(const QString &device)
{
QFile file(QStringLiteral("/sys/class/video4linux/%1/name").arg(QFileInfo(device).fileName()));
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
return QString::fromUtf8(file.readAll()).trimmed();
}
// Opens the node just long enough to ask what it is. Neither this nor the
// format list below starts streaming, so it does not switch the light on.
bool probe(const QString &path, CameraInfo *info)
{
const int fd = ::open(QFile::encodeName(path).constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC);
if (fd < 0) {
return false;
}
v4l2_capability cap{};
bool ok = ::ioctl(fd, VIDIOC_QUERYCAP, &cap) == 0;
if (ok) {
const quint32 caps = (cap.capabilities & V4L2_CAP_DEVICE_CAPS) ? cap.device_caps : cap.capabilities;
ok = (caps & V4L2_CAP_VIDEO_CAPTURE) && !(caps & V4L2_CAP_META_CAPTURE);
}
bool colour = false;
bool grey = false;
if (ok) {
for (quint32 i = 0;; ++i) {
v4l2_fmtdesc fmt{};
fmt.index = i;
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (::ioctl(fd, VIDIOC_ENUM_FMT, &fmt) != 0) {
break;
}
switch (fmt.pixelformat) {
case V4L2_PIX_FMT_GREY:
case V4L2_PIX_FMT_Y10:
case V4L2_PIX_FMT_Y12:
case V4L2_PIX_FMT_Y16:
grey = true;
break;
default:
colour = true;
break;
}
}
ok = colour || grey;
}
::close(fd);
if (ok && info) {
info->path = path;
info->name = sysName(path);
if (info->name.isEmpty()) {
info->name = QString::fromUtf8(reinterpret_cast<const char *>(cap.card));
}
info->infrared = grey && !colour;
}
return ok;
}
} // namespace
Camera::Camera() = default;
Camera::~Camera()
{
close();
}
QList<CameraInfo> Camera::list()
{
QList<CameraInfo> result;
const QDir dev(QStringLiteral("/dev"));
QStringList nodes = dev.entryList({QStringLiteral("video*")}, QDir::System);
std::sort(nodes.begin(), nodes.end(), [](const QString &a, const QString &b) {
return a.mid(5).toInt() < b.mid(5).toInt();
});
for (const QString &node : std::as_const(nodes)) {
CameraInfo info;
if (probe(dev.filePath(node), &info)) {
result.append(info);
}
}
return result;
}
QString Camera::autoPath()
{
const QList<CameraInfo> cameras = list();
for (const CameraInfo &c : cameras) {
if (!c.infrared) {
return c.path;
}
}
return cameras.isEmpty() ? QString() : cameras.first().path;
}
bool Camera::open(const QString &spec, QString *error)
{
close();
m_start = steady_clock::now();
m_next = m_start;
if (spec.startsWith(u"images:")) {
return openImages(spec.mid(7), error);
}
if (spec.startsWith(u"file:")) {
const QString path = spec.mid(5);
if (!m_capture.open(QFile::encodeName(path).toStdString(), cv::CAP_ANY) || !m_capture.isOpened()) {
*error = QStringLiteral("cannot open video file %1").arg(path);
return false;
}
m_paced = true;
m_open = true;
m_description = QFileInfo(path).fileName();
return true;
}
QString path = spec;
if (path.isEmpty() || path == u"auto") {
path = autoPath();
if (path.isEmpty()) {
*error = QStringLiteral("no camera found");
return false;
}
}
CameraInfo info;
if (!probe(path, &info)) {
*error = QStringLiteral("%1 is not a camera").arg(path);
return false;
}
if (!m_capture.open(QFile::encodeName(path).toStdString(), cv::CAP_V4L2) || !m_capture.isOpened()) {
*error = QStringLiteral("cannot open %1 (in use by another program?)").arg(path);
return false;
}
// MJPEG gets a webcam its full frame rate over USB 2; raw YUYV at 640x480
// often tops out at 15 fps. A camera that has no MJPEG ignores the request.
if (!info.infrared) {
m_capture.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G'));
}
m_capture.set(cv::CAP_PROP_FRAME_WIDTH, Width);
m_capture.set(cv::CAP_PROP_FRAME_HEIGHT, Height);
m_capture.set(cv::CAP_PROP_FPS, 30);
// A stale frame in the driver's queue is a picture of whoever sat there a
// moment ago. Keep the queue as short as the driver allows.
m_capture.set(cv::CAP_PROP_BUFFERSIZE, 1);
m_infrared = info.infrared;
m_paced = false;
m_open = true;
m_description = QStringLiteral("%1 (%2)").arg(info.name, path);
return true;
}
bool Camera::openImages(const QString &dir, QString *error)
{
const QDir d(dir);
const QStringList files = d.entryList({QStringLiteral("*.jpg"), QStringLiteral("*.jpeg"), QStringLiteral("*.png")},
QDir::Files, QDir::Name);
for (const QString &f : files) {
cv::Mat img = cv::imread(QFile::encodeName(d.filePath(f)).toStdString(), cv::IMREAD_COLOR);
if (img.empty()) {
continue;
}
const double scale = double(Width) / std::max(img.cols, img.rows);
if (scale < 1.0) {
cv::resize(img, img, {}, scale, scale, cv::INTER_AREA);
}
m_images.append(img);
}
if (m_images.isEmpty()) {
*error = QStringLiteral("no pictures in %1").arg(dir);
return false;
}
m_imageIndex = 0;
m_imageRepeat = 0;
m_paced = true;
m_open = true;
m_description = QStringLiteral("pictures in %1").arg(dir);
return true;
}
void Camera::close()
{
if (m_capture.isOpened()) {
m_capture.release();
}
m_images.clear();
m_open = false;
m_infrared = false;
}
bool Camera::isOpen() const
{
return m_open;
}
void Camera::pace(double frameMs)
{
m_next += duration_cast<steady_clock::duration>(duration<double, std::milli>(frameMs));
const auto now = steady_clock::now();
if (m_next > now) {
std::this_thread::sleep_until(m_next);
} else {
m_next = now;
}
}
bool Camera::readImages(cv::Mat &bgr)
{
pace(ImageFrameMs);
bgr = m_images.at(m_imageIndex).clone();
if (++m_imageRepeat >= ImageRepeat) {
m_imageRepeat = 0;
m_imageIndex = (m_imageIndex + 1) % m_images.size();
}
return true;
}
bool Camera::read(cv::Mat &bgr, double *timestampMs)
{
if (!m_open) {
return false;
}
bool ok;
if (!m_images.isEmpty()) {
ok = readImages(bgr);
} else {
if (m_paced) {
double fps = m_capture.get(cv::CAP_PROP_FPS);
if (!(fps > 1 && fps < 240)) {
fps = 30;
}
pace(1000.0 / fps);
}
ok = m_capture.read(bgr) && !bgr.empty();
}
if (!ok) {
return false;
}
if (bgr.channels() == 1) {
cv::cvtColor(bgr, bgr, cv::COLOR_GRAY2BGR);
}
if (timestampMs) {
*timestampMs = duration<double, std::milli>(steady_clock::now() - m_start).count();
}
return true;
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Where frames come from.
//
// Normally a V4L2 device. For testing without a camera (a virtual machine, a
// build server) a video file or a folder of pictures stands in for one and is
// played back at the pace a camera would deliver it, so that anything timed in
// the liveness checks behaves the way it would with the real thing.
#pragma once
#include <QList>
#include <QString>
#include <opencv2/core.hpp>
#include <opencv2/videoio.hpp>
#include <chrono>
#include <memory>
struct CameraInfo {
QString path;
QString name;
// Infrared cameras (the kind Windows Hello uses) only offer grey formats.
bool infrared = false;
};
class Camera
{
public:
Camera();
~Camera();
// spec is a /dev/video path, "auto", "file:<video>" or "images:<dir>".
bool open(const QString &spec, QString *error);
void close();
bool isOpen() const;
// Blocks until the next frame. The timestamp is in milliseconds on a
// monotonic clock and only means something relative to other frames.
bool read(cv::Mat &bgr, double *timestampMs);
bool isInfrared() const
{
return m_infrared;
}
QString description() const
{
return m_description;
}
// Every V4L2 device that can capture video. Metadata nodes, which every
// UVC camera also exposes, are left out.
static QList<CameraInfo> list();
// What "auto" means on this machine: the first colour camera, else the
// first camera at all.
static QString autoPath();
private:
bool openImages(const QString &dir, QString *error);
bool readImages(cv::Mat &bgr);
void pace(double frameMs);
cv::VideoCapture m_capture;
bool m_open = false;
bool m_infrared = false;
bool m_paced = false;
QString m_description;
QList<cv::Mat> m_images;
qsizetype m_imageIndex = 0;
int m_imageRepeat = 0;
std::chrono::steady_clock::time_point m_start;
std::chrono::steady_clock::time_point m_next;
};
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// OpenCV 5 moved the contour and transform helpers (getPerspectiveTransform,
// approxPolyDP and friends) out of imgproc into a module of their own. The
// distributions this builds on ship 4.x and 5.x, so both are included here.
#pragma once
#include <opencv2/core/version.hpp>
#include <opencv2/imgproc.hpp>
#if CV_VERSION_MAJOR >= 5
#include <opencv2/geometry.hpp>
#endif
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "keyvalue.h"
#include <QFile>
KeyValueFile KeyValueFile::load(const QString &path)
{
KeyValueFile kv;
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return kv;
}
while (!file.atEnd()) {
QString line = QString::fromUtf8(file.readLine()).trimmed();
if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) {
continue;
}
const qsizetype eq = line.indexOf(QLatin1Char('='));
if (eq <= 0) {
continue;
}
const QString key = line.left(eq).trimmed();
QString value = line.mid(eq + 1);
// A comment can follow a value, the same as in the shell reader.
const qsizetype hash = value.indexOf(QLatin1Char('#'));
if (hash >= 0) {
value.truncate(hash);
}
value = value.trimmed();
if (value.size() >= 2 && value.startsWith(QLatin1Char('"')) && value.endsWith(QLatin1Char('"'))) {
value = value.mid(1, value.size() - 2);
}
// The last occurrence wins, which is also what the shell reader does.
kv.m_values.insert(key, value);
}
return kv;
}
QString KeyValueFile::value(const QString &key, const QString &fallback) const
{
const auto it = m_values.constFind(key);
if (it == m_values.cend() || it->isEmpty()) {
return fallback;
}
return *it;
}
bool KeyValueFile::contains(const QString &key) const
{
return m_values.contains(key);
}
bool KeyValueFile::parseBool(const QString &value, bool fallback)
{
const QString v = value.trimmed().toLower();
if (v == u"yes" || v == u"y" || v == u"true" || v == u"1" || v == u"on" || v == u"enabled") {
return true;
}
if (v == u"no" || v == u"n" || v == u"false" || v == u"0" || v == u"off" || v == u"disabled") {
return false;
}
return fallback;
}
bool KeyValueFile::boolean(const QString &key, bool fallback) const
{
return parseBool(value(key), fallback);
}
int KeyValueFile::integer(const QString &key, int fallback, int min, int max) const
{
bool ok = false;
const int v = value(key).toInt(&ok);
if (!ok) {
return fallback;
}
return std::clamp(v, min, max);
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The settings files, read the same way the shell code reads them: one
// Key=Value per line, '#' starts a comment, quotes around a value are dropped.
// Both halves of the program write and read these files, so they have to
// agree on every detail of the format.
#pragma once
#include <QHash>
#include <QString>
class KeyValueFile
{
public:
static KeyValueFile load(const QString &path);
QString value(const QString &key, const QString &fallback = {}) const;
bool boolean(const QString &key, bool fallback) const;
int integer(const QString &key, int fallback, int min, int max) const;
bool contains(const QString &key) const;
static bool parseBool(const QString &value, bool fallback);
private:
QHash<QString, QString> m_values;
};
+535
View File
@@ -0,0 +1,535 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "liveness.h"
#include "cvcompat.h"
#include <algorithm>
#include <cmath>
namespace
{
// The eyes are levelled and scaled onto a fixed canvas before anything is
// measured, so the numbers below are in canvas pixels and mean the same at
// any distance and any head tilt. 64 pixels between the eyes.
constexpr int Canvas = 128;
constexpr float CanvasIod = 64.f;
constexpr int EyeY = Canvas / 2;
constexpr int RightEyeX = Canvas / 2 - int(CanvasIod / 2);
constexpr int LeftEyeX = Canvas / 2 + int(CanvasIod / 2);
// A slice through the middle of the eye, narrow enough to stay on the iris
// and tall enough to hold a fully open one (about 0.3 eye distances).
constexpr int BandHalfWidth = 7;
constexpr int BandHalfHeight = 10;
// Skin under the eye, clear of lashes and of the shadow below the lid.
constexpr int SkinTop = EyeY + 22;
constexpr int SkinBottom = EyeY + 32;
constexpr int SkinHalfWidth = 12;
// A row of the slice counts as dark below this share of the skin's
// brightness. Iris and pupil are well below it on every skin tone that
// was checked; a closed lid is skin and sits well above it.
constexpr float DarkShare = 0.7f;
// Glare: the Y floor and the chroma tolerance for "colourless and nearly
// white", in YCrCb.
constexpr int SpecularLuma = 235;
constexpr int SpecularChroma = 10;
constexpr int GlareGrid = 8;
cv::Mat levelledFace(const cv::Mat &bgr, const Face &face)
{
const cv::Point2f mid = face.eyeMid();
const cv::Point2f d = face.points[LeftEye] - face.points[RightEye];
const double roll = std::atan2(d.y, d.x) * 180.0 / M_PI;
const double scale = CanvasIod / std::max(1.f, face.interocular());
cv::Mat m = cv::getRotationMatrix2D(mid, roll, scale);
m.at<double>(0, 2) += Canvas / 2.0 - mid.x;
m.at<double>(1, 2) += Canvas / 2.0 - mid.y;
cv::Mat grey, out;
cv::cvtColor(bgr, grey, cv::COLOR_BGR2GRAY);
cv::warpAffine(grey, out, m, cv::Size(Canvas, Canvas), cv::INTER_LINEAR, cv::BORDER_REPLICATE);
return out;
}
float eyeOpenness(const cv::Mat &canvas, int eyeX, float *skinOut)
{
const cv::Rect skinRect(eyeX - SkinHalfWidth, SkinTop, 2 * SkinHalfWidth, SkinBottom - SkinTop);
const float skin = float(cv::mean(canvas(skinRect))[0]);
*skinOut = skin;
if (skin < 20) {
return -1;
}
const cv::Rect band(eyeX - BandHalfWidth, EyeY - BandHalfHeight, 2 * BandHalfWidth + 1, 2 * BandHalfHeight + 1);
cv::Mat rows;
cv::reduce(canvas(band), rows, 1, cv::REDUCE_AVG, CV_32F);
int dark = 0;
for (int y = 0; y < rows.rows; ++y) {
if (rows.at<float>(y, 0) < DarkShare * skin) {
++dark;
}
}
return float(dark) / float(rows.rows);
}
bool insidePolygon(const std::vector<cv::Point> &poly, cv::Point2f p)
{
return cv::pointPolygonTest(poly, p, false) >= 0;
}
} // namespace
GlareSample measureGlare(const cv::Mat &bgr, const Face &face)
{
GlareSample g;
const float iod = face.interocular();
const cv::Point2f centre = (face.eyeMid() + face.mouthMid()) * 0.5f;
cv::Rect roi(int(centre.x - 1.1f * iod), int(centre.y - 1.3f * iod), int(2.2f * iod), int(2.4f * iod));
roi &= cv::Rect(0, 0, bgr.cols, bgr.rows);
if (roi.width < 16 || roi.height < 16) {
return g;
}
cv::Mat ycc;
cv::cvtColor(bgr(roi), ycc, cv::COLOR_BGR2YCrCb);
// Glasses throw the screen back from right in front of the eyes, and
// that is a big flat highlight too. It is not what is being looked for,
// so the eyes are left out.
cv::Mat mask(roi.size(), CV_8U, cv::Scalar(255));
for (int e : {RightEye, LeftEye}) {
const cv::Point2f p = face.points[e] - cv::Point2f(float(roi.x), float(roi.y));
cv::circle(mask, p, int(0.38f * iod), cv::Scalar(0), cv::FILLED);
}
std::array<int, GlareGrid * GlareGrid> cells{};
int total = 0;
int considered = 0;
for (int y = 0; y < ycc.rows; ++y) {
const cv::Vec3b *row = ycc.ptr<cv::Vec3b>(y);
const uchar *m = mask.ptr<uchar>(y);
const int gy = std::min(GlareGrid - 1, y * GlareGrid / ycc.rows);
for (int x = 0; x < ycc.cols; ++x) {
if (!m[x]) {
continue;
}
++considered;
const cv::Vec3b &p = row[x];
if (p[0] < SpecularLuma || std::abs(p[1] - 128) > SpecularChroma || std::abs(p[2] - 128) > SpecularChroma) {
continue;
}
++total;
const int gx = std::min(GlareGrid - 1, x * GlareGrid / ycc.cols);
++cells[gy * GlareGrid + gx];
}
}
g.valid = considered > 0;
g.fraction = considered ? float(total) / float(considered) : 0.f;
// Too few pixels to say anything about their shape.
g.cluster = total >= 24 ? float(*std::max_element(cells.begin(), cells.end())) / float(total) : 0.f;
return g;
}
EyeSample measureEyes(const cv::Mat &bgr, const Face &face)
{
EyeSample s;
if (face.interocular() < 20.f) {
return s;
}
const cv::Mat canvas = levelledFace(bgr, face);
// "Right" is the person's right eye, which is on the canvas' left.
float skinR = 0, skinL = 0;
s.right = eyeOpenness(canvas, RightEyeX, &skinR);
s.left = eyeOpenness(canvas, LeftEyeX, &skinL);
if (s.right < 0 || s.left < 0) {
return s;
}
s.openness = (s.left + s.right) / 2;
s.skin = (skinL + skinR) / 2;
s.valid = true;
return s;
}
bool detectDevice(const cv::Mat &bgr, const Face &face)
{
// Edges are looked for at half size. The bezel of a phone held up to the
// camera is big, and small detail would only add rectangles that are not
// one.
const double scale = 320.0 / std::max(1, bgr.cols);
cv::Mat small, grey, edges;
cv::resize(bgr, small, cv::Size(), scale, scale, cv::INTER_AREA);
cv::cvtColor(small, grey, cv::COLOR_BGR2GRAY);
cv::GaussianBlur(grey, grey, cv::Size(5, 5), 0);
cv::Canny(grey, edges, 40, 120);
cv::dilate(edges, edges, cv::Mat(), cv::Point(-1, -1), 1);
std::vector<std::vector<cv::Point>> contours;
cv::findContours(edges, contours, cv::RETR_LIST, cv::CHAIN_APPROX_SIMPLE);
const double frameArea = double(small.cols) * small.rows;
const double faceArea = double(face.box.area()) * scale * scale;
std::array<cv::Point2f, 5> points;
for (int i = 0; i < 5; ++i) {
points[i] = face.points[i] * float(scale);
}
for (const auto &c : contours) {
const double area = std::abs(cv::contourArea(c));
// A device held up to take somebody's place fills a good part of the
// picture, and the face fills a good part of the device. A door or a
// monitor far behind somebody's head does neither.
if (area < 0.10 * frameArea || area > 0.95 * frameArea || area < 1.6 * faceArea || faceArea / area < 0.08) {
continue;
}
std::vector<cv::Point> quad;
cv::approxPolyDP(c, quad, 0.03 * cv::arcLength(c, true), true);
if (quad.size() != 4 || !cv::isContourConvex(quad)) {
continue;
}
const cv::RotatedRect r = cv::minAreaRect(quad);
const float shortSide = std::min(r.size.width, r.size.height);
const float longSide = std::max(r.size.width, r.size.height);
if (longSide <= 0 || shortSide / longSide < 0.3f) {
continue;
}
// It has to frame the face: every one of the five points inside.
bool framed = true;
for (const cv::Point2f &p : points) {
framed = framed && insidePolygon(quad, p);
}
if (framed) {
return true;
}
}
return false;
}
LivenessFrame measureFrame(const cv::Mat &bgr, const Face &face, double timestampMs)
{
LivenessFrame f;
f.t = timestampMs;
f.points = face.points;
f.interocular = face.interocular();
f.pose = estimatePose(face);
f.glare = measureGlare(bgr, face);
f.device = detectDevice(bgr, face);
f.eyes = measureEyes(bgr, face);
return f;
}
// ---------------------------------------------------------------------------
// The analyzer
// ---------------------------------------------------------------------------
void LivenessAnalyzer::reset()
{
m_frames.clear();
m_total = 0;
m_glareHits = 0;
m_deviceHits = 0;
m_depthFired = false;
m_blinkFired = false;
m_depth.clear();
m_depthNum = 0;
m_depthDen = 0;
m_depthPairs = 0;
}
void LivenessAnalyzer::add(const LivenessFrame &frame)
{
m_frames.push_back(frame);
while (!m_frames.empty() && frame.t - m_frames.front().t > WindowMs) {
m_frames.pop_front();
}
// Deny cues count over the whole scan, not just the window: a phone that
// was seen once does not stop having been a phone.
++m_total;
if (frame.glare.valid && frame.glare.fraction >= GlareFraction && frame.glare.cluster >= GlareCluster) {
++m_glareHits;
}
if (frame.device) {
++m_deviceHits;
}
addDepth(frame);
// Confirm cues latch once seen, for the rest of this scan.
if (!m_depthFired) {
m_depthFired = depthRange() >= DepthMinRange && m_depthPairs >= 6 && depthRatio() >= DepthMinRatio && depthConsistent();
}
if (!m_blinkFired) {
float strength = 0;
m_blinkFired = blinkSeen(&strength);
}
}
// How far the nose misses the spot a flat face would put it, measured against
// how far the head turned.
//
// For a pair of frames far enough apart in yaw, the four points that lie close
// to one plane (eyes, mouth corners) give the homography between the two
// frames. A point on a flat picture lands exactly where it predicts. The real
// nose tip is about a third of an eye distance in front of that plane, so it
// lands off the prediction, sideways, by about as much as the yaw estimate
// changed: both are the same parallax, seen two ways. The slope of miss
// against yaw change is therefore near 1 for anything with a nose sticking
// out of it, and near 0 for paper or a screen.
//
// Two details decide whether that holds up against a real camera.
//
// The yaw a frame is compared at comes from its neighbours, never from the
// frame itself. Yaw and miss are both read off the same nose, so the nose's
// own jitter would push both the same way in every frame, and the slope of
// noise against itself is 1. That alone made a photo shaken at two pixels of
// jitter pass as a head. The neighbours are 50 ms away, so they see the same
// head position with jitter of their own.
//
// And the slope alone says nothing about depth, only that nose and plane
// disagree consistently. A card curled around a vertical axis has a little
// depth too, and its slope is near 1 as well. What it cannot do is move the
// nose off the midline by much, so the cue also needs the (smoothed) yaw to
// have covered DepthMinRange: about 12 degrees of a real head turning.
//
// A card that is curled hard and turned far enough still gets there. So the
// last check asks whether the face turned as far as its nose says it did.
// Turning narrows the eyes against the eye to mouth distance by 1 - cos(turn),
// whatever the face is made of. A nose as flat as a real one can be (0.3 eye
// distances) that moved DepthMinRange can only have turned so far, and a face
// that narrowed a lot more than that was turned a lot more than that: it has
// less nose than any head. See depthConsistent().
void LivenessAnalyzer::addDepth(const LivenessFrame &frame)
{
constexpr size_t MaxFrames = 400;
if (m_depth.size() >= MaxFrames) {
return;
}
Face face;
face.points = frame.points;
const float emd = float(cv::norm(face.mouthMid() - face.eyeMid()));
const float shape = emd > 1.f ? face.interocular() / emd : 0.f;
m_depth.push_back({frame.points, frame.pose.yaw, 0.f, frame.pose.noseT, shape, 0.f});
// The frame two back now has two neighbours on each side.
const size_t n = m_depth.size();
if (n < 5) {
return;
}
const size_t j = n - 3;
DepthPoint &b = m_depth[j];
b.smoothYaw = (m_depth[j - 2].yaw + m_depth[j - 1].yaw + m_depth[j + 1].yaw + m_depth[j + 2].yaw) / 4.f;
b.smoothShape = (m_depth[j - 2].shape + m_depth[j - 1].shape + b.shape + m_depth[j + 1].shape + m_depth[j + 2].shape) / 5.f;
const cv::Point2f axis = b.points[LeftEye] - b.points[RightEye];
const float axisLen = std::max(1e-3f, float(cv::norm(axis)));
const cv::Point2f unit = axis * (1.f / axisLen);
for (size_t i = 2; i < j; ++i) {
const DepthPoint &a = m_depth[i];
const float dy = b.smoothYaw - a.smoothYaw;
if (std::abs(dy) < DepthMinTurn) {
continue;
}
const cv::Point2f src[4] = {a.points[RightEye], a.points[LeftEye], a.points[LeftMouth], a.points[RightMouth]};
const cv::Point2f dst[4] = {b.points[RightEye], b.points[LeftEye], b.points[LeftMouth], b.points[RightMouth]};
const cv::Mat h = cv::getPerspectiveTransform(src, dst);
if (h.empty()) {
continue;
}
std::vector<cv::Point2f> in{a.points[NoseTip]}, out;
cv::perspectiveTransform(in, out, h);
const cv::Point2f miss = b.points[NoseTip] - out[0];
const float rx = (miss.x * unit.x + miss.y * unit.y) / axisLen;
m_depthNum += double(rx) * dy;
m_depthDen += double(dy) * dy;
++m_depthPairs;
}
}
float LivenessAnalyzer::depthRatio() const
{
return m_depthDen > 0 ? float(m_depthNum / m_depthDen) : 0.f;
}
// The spread of the smoothed yaw, from the 10th to the 90th percentile, so a
// single bad frame cannot stretch it.
float LivenessAnalyzer::depthRange() const
{
if (m_depth.size() < 5) {
return 0;
}
std::vector<float> ys;
for (size_t k = 2; k + 2 < m_depth.size(); ++k) {
ys.push_back(m_depth[k].smoothYaw);
}
if (ys.size() < 3) {
return 0;
}
std::sort(ys.begin(), ys.end());
const auto at = [&](double q) {
return ys[size_t(q * double(ys.size() - 1))];
};
return at(0.9) - at(0.1);
}
bool LivenessAnalyzer::depthConsistent() const
{
// Nodding also changes the eye to mouth distance, so only frames at the
// head's usual pitch are compared. A card has no pitch of its own to read
// (its nose is printed on), so none of its frames are left out.
std::vector<float> noseTs;
for (size_t k = 2; k + 2 < m_depth.size(); ++k) {
noseTs.push_back(m_depth[k].noseT);
}
if (noseTs.size() < 3) {
return false;
}
std::vector<float> sortedT = noseTs;
std::sort(sortedT.begin(), sortedT.end());
const float medianT = sortedT[sortedT.size() / 2];
std::vector<float> shapes;
for (size_t k = 2; k + 2 < m_depth.size(); ++k) {
if (std::abs(m_depth[k].noseT - medianT) < 0.05f) {
shapes.push_back(m_depth[k].smoothShape);
}
}
if (shapes.size() < 3) {
return false;
}
std::sort(shapes.begin(), shapes.end());
const float widest = shapes[size_t(0.95 * double(shapes.size() - 1))];
const float narrowest = shapes[size_t(0.10 * double(shapes.size() - 1))];
if (widest <= 0) {
return false;
}
const float narrowed = 1.f - narrowest / widest;
const float sinTurn = std::min(1.f, depthRange() / DepthFlattestNose);
const float allowed = 1.f - std::sqrt(1.f - sinTurn * sinTurn);
return narrowed <= allowed + DepthShapeSlack;
}
bool LivenessAnalyzer::blinkSeen(float *strength) const
{
*strength = 0;
std::vector<const LivenessFrame *> s;
for (const LivenessFrame &f : m_frames) {
if (f.eyes.valid) {
s.push_back(&f);
}
}
if (s.size() < 6) {
return false;
}
std::vector<float> values;
for (const LivenessFrame *f : s) {
values.push_back(f->eyes.openness);
}
std::vector<float> sorted = values;
std::sort(sorted.begin(), sorted.end());
const float baseline = sorted[size_t(0.7 * double(sorted.size() - 1))];
// An eye this narrow is not one the measure works on (heavy lids, very
// dark eyes on dark skin, a camera that is too soft). Better to abstain
// than to guess.
if (baseline < 0.15f) {
return false;
}
*strength = std::clamp(1.f - sorted.front() / baseline, 0.f, 1.f) / (1.f - BlinkDip);
const size_t n = s.size();
for (size_t i = 1; i + 1 < n; ++i) {
if (values[i] >= BlinkDip * baseline) {
continue;
}
// The run of closed frames around this one.
size_t a = i, b = i;
while (a > 0 && values[a - 1] < BlinkOpen * baseline) {
--a;
}
while (b + 1 < n && values[b + 1] < BlinkOpen * baseline) {
++b;
}
if (a == 0 || b + 1 >= n) {
continue;
}
// Open right before and right after, within the time a blink takes
// (a real one is 100 to 400 ms; slower is somebody closing their
// eyes, or a picture being tilted away and back).
const LivenessFrame *before = s[a - 1];
const LivenessFrame *after = s[b + 1];
if (after->t - before->t > 600) {
continue;
}
// The rest of the face held still. A picture being moved blurs and
// shifts everything at once; a blink only moves the lids.
const cv::Point2f moved = (after->points[NoseTip] - before->points[NoseTip]);
const float iod = std::max(1.f, before->interocular);
if (float(cv::norm(moved)) > 0.15f * iod) {
continue;
}
if (std::abs(after->interocular - before->interocular) > 0.08f * iod) {
continue;
}
bool steadyLight = true;
for (size_t k = a; k <= b; ++k) {
const float ratio = s[k]->eyes.skin / std::max(1.f, before->eyes.skin);
steadyLight = steadyLight && ratio > 0.9f && ratio < 1.1f;
}
if (!steadyLight) {
continue;
}
// Both eyes together. One eye going dark on its own is a shadow or a
// hand, not a blink.
bool both = false;
for (size_t k = a; k <= b && !both; ++k) {
both = s[k]->eyes.left < 0.7f * baseline && s[k]->eyes.right < 0.7f * baseline;
}
if (both) {
return true;
}
}
return false;
}
LivenessReading LivenessAnalyzer::reading() const
{
LivenessReading r;
r.frames = int(m_frames.size());
const int seen = std::max(1, m_total);
const float glareShare = float(m_glareHits) / float(seen);
const float deviceShare = float(m_deviceHits) / float(seen);
r.glare = std::min(glareShare / 0.3f, float(m_glareHits) / 3.f);
r.device = std::min(deviceShare / 0.3f, float(m_deviceHits) / 3.f);
if (m_glareHits >= 3 && glareShare >= 0.3f) {
r.denied = true;
r.deniedBy = QStringLiteral("glare");
} else if (m_deviceHits >= 3 && deviceShare >= 0.3f) {
r.denied = true;
r.deniedBy = QStringLiteral("device");
}
r.depth = m_depthFired ? 1.f
: std::clamp(depthRatio() / DepthMinRatio, 0.f, 1.f) * std::clamp(depthRange() / DepthMinRange, 0.f, 1.f);
float strength = 0;
blinkSeen(&strength);
r.blink = m_blinkFired ? 1.f : std::min(strength, 0.99f);
if (m_depthFired) {
r.confirmed = true;
r.confirmedBy = QStringLiteral("depth");
} else if (m_blinkFired) {
r.confirmed = true;
r.confirmedBy = QStringLiteral("blink");
}
return r;
}
+154
View File
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Telling a face from a picture of one.
//
// A webcam sees a flat image either way, so there is no single test that
// settles it. What is here follows the model Glance (the macOS face unlock)
// arrived at after trying and dropping a dozen weaker signals: a few cues,
// each one decisive on its own, split into two kinds.
//
// Deny cues are evidence of a fake. Either one fails the scan outright, and
// a match that already happened does not outvote it.
//
// glare a phone screen or a glossy print throws back one large, flat,
// colourless highlight. Skin shines in small scattered spots.
// device the straight edges of a phone or a tablet around the face.
//
// Confirm cues are evidence of a real head. Any one of them is enough, and
// their absence is never held against anybody on its own: a person can sit
// still and not blink for a few seconds.
//
// depth when the head turns, the tip of the nose moves further than a
// flat face would let it. The eyes and the corners of the mouth lie
// close to one plane, so four of them predict exactly where the
// nose of a flat picture has to go. A real nose, about a third of
// an eye distance in front of that plane, misses the prediction by
// about as much as the head turned.
// blink the dark of the eye shrinks to a line and comes back, within
// the fraction of a second a blink takes, while the rest of the face
// holds still.
//
// "Light" uses the deny cues. "Heavy" also needs one confirm cue before it
// lets anybody in.
#pragma once
#include "settings.h"
#include "vision.h"
#include <QString>
#include <deque>
struct GlareSample {
bool valid = false;
// Share of the face that is a colourless highlight.
float fraction = 0;
// Share of all highlight pixels that fall in the fullest of 8x8 cells.
// One slab of glass reflects into one place; skin sparkles everywhere.
float cluster = 0;
};
struct EyeSample {
bool valid = false;
// How much of the eye's height is dark (iris, pupil), per eye and on
// average. Falls towards zero when the lid comes down.
float left = 0;
float right = 0;
float openness = 0;
// Brightness of the skin under the eyes, to tell a blink from a change
// in the light.
float skin = 0;
};
struct LivenessFrame {
double t = 0;
std::array<cv::Point2f, 5> points{};
float interocular = 0;
HeadPose pose;
GlareSample glare;
bool device = false;
EyeSample eyes;
};
GlareSample measureGlare(const cv::Mat &bgr, const Face &face);
EyeSample measureEyes(const cv::Mat &bgr, const Face &face);
bool detectDevice(const cv::Mat &bgr, const Face &face);
LivenessFrame measureFrame(const cv::Mat &bgr, const Face &face, double timestampMs);
struct LivenessReading {
int frames = 0;
bool denied = false;
QString deniedBy;
bool confirmed = false;
QString confirmedBy;
// For the test screen: how close each cue is to firing, 0 to 1 and more.
float glare = 0;
float device = 0;
float depth = 0;
float blink = 0;
};
class LivenessAnalyzer
{
public:
void reset();
void add(const LivenessFrame &frame);
LivenessReading reading() const;
int frameCount() const
{
return int(m_frames.size());
}
// Tuning, in one place. See liveness.cpp for where each number comes
// from.
static constexpr double WindowMs = 4000;
static constexpr float GlareFraction = 0.012f;
static constexpr float GlareCluster = 0.55f;
static constexpr float DepthMinTurn = 0.05f;
static constexpr float DepthMinRange = 0.10f;
static constexpr float DepthMinRatio = 0.65f;
static constexpr float DepthFlattestNose = 0.30f;
static constexpr float DepthShapeSlack = 0.02f;
static constexpr float BlinkDip = 0.55f;
static constexpr float BlinkOpen = 0.8f;
// The depth cue's workings, for the test screen and the tests.
float depthRatio() const;
float depthRange() const;
bool depthConsistent() const;
private:
void addDepth(const LivenessFrame &frame);
bool blinkSeen(float *strength) const;
std::deque<LivenessFrame> m_frames;
int m_total = 0;
// The depth cue looks at the whole scan rather than the window, and is
// worked out a frame at a time so it stays cheap.
struct DepthPoint {
std::array<cv::Point2f, 5> points;
float yaw;
float smoothYaw;
float noseT;
// Eye distance over eye to mouth distance: shrinks as the face turns
// away from the camera, whatever the face is made of.
float shape;
float smoothShape;
};
std::vector<DepthPoint> m_depth;
double m_depthNum = 0;
double m_depthDen = 0;
int m_depthPairs = 0;
int m_glareHits = 0;
int m_deviceHits = 0;
bool m_depthFired = false;
bool m_blinkFired = false;
};
+81
View File
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "settings.h"
#include "keyvalue.h"
double Settings::threshold() const
{
// Cosine similarity between two SFace embeddings. Photos of the same
// person taken years apart land around 0.75, different people below 0.3.
// OpenCV's own recommendation (0.363) is tuned for telling apart photos
// in a data set; this guards a login, so the bar is higher.
switch (strictness) {
case Strictness::Relaxed:
return 0.42;
case Strictness::Normal:
return 0.50;
case Strictness::Strict:
return 0.58;
}
return 0.50;
}
Settings Settings::load(const QString &path)
{
Settings s;
const KeyValueFile kv = KeyValueFile::load(path);
s.camera = kv.value(QStringLiteral("Camera"), s.camera);
const QString liveness = kv.value(QStringLiteral("Liveness")).toLower();
if (liveness == u"off") {
s.liveness = LivenessMode::Off;
} else if (liveness == u"light") {
s.liveness = LivenessMode::Light;
} else if (liveness == u"heavy") {
s.liveness = LivenessMode::Heavy;
}
const QString strictness = kv.value(QStringLiteral("Strictness")).toLower();
if (strictness == u"relaxed") {
s.strictness = Strictness::Relaxed;
} else if (strictness == u"normal") {
s.strictness = Strictness::Normal;
} else if (strictness == u"strict") {
s.strictness = Strictness::Strict;
}
s.attention = kv.boolean(QStringLiteral("Attention"), s.attention);
s.scanSeconds = kv.integer(QStringLiteral("ScanSeconds"), s.scanSeconds, 2, 15);
s.maxFailures = kv.integer(QStringLiteral("MaxFailures"), s.maxFailures, 1, 20);
s.lockoutMinutes = kv.integer(QStringLiteral("LockoutMinutes"), s.lockoutMinutes, 1, 24 * 60);
s.skipLidClosed = kv.boolean(QStringLiteral("SkipLidClosed"), s.skipLidClosed);
s.adapt = kv.boolean(QStringLiteral("Adapt"), s.adapt);
return s;
}
QString Settings::livenessName(LivenessMode mode)
{
switch (mode) {
case LivenessMode::Off:
return QStringLiteral("off");
case LivenessMode::Light:
return QStringLiteral("light");
case LivenessMode::Heavy:
return QStringLiteral("heavy");
}
return {};
}
QString Settings::strictnessName(Strictness strictness)
{
switch (strictness) {
case Strictness::Relaxed:
return QStringLiteral("relaxed");
case Strictness::Normal:
return QStringLiteral("normal");
case Strictness::Strict:
return QStringLiteral("strict");
}
return {};
}
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The system settings, /etc/plasma-face-unlock/config.
//
// These are the ones that decide how hard it is to get in: which camera, how
// strict the match is, whether a photo is checked for. They belong to root
// for that reason. A setting a user process could change would be a setting
// any program running as that user could lower before asking sudo for help.
#pragma once
#include <QString>
enum class LivenessMode {
Off,
// Deny cues only: glare off a screen, the edge of a phone around the face.
Light,
// Deny cues, and a sign of life on top: a blink, or the nose moving like
// a nose does when the head turns.
Heavy,
};
enum class Strictness {
Relaxed,
Normal,
Strict,
};
struct Settings {
// A /dev/video path, "auto", or for testing "file:<video>" and
// "images:<directory>".
QString camera = QStringLiteral("auto");
LivenessMode liveness = LivenessMode::Heavy;
Strictness strictness = Strictness::Normal;
// Only a face that looks at the screen with its eyes open counts.
bool attention = true;
// How long one scan looks before it gives up.
int scanSeconds = 5;
// Failed scans in a row with a face in view before face unlock stops
// until the password has been used, and how long that lasts at most.
int maxFailures = 5;
int lockoutMinutes = 15;
// A laptop with the lid shut has its camera looking at the keyboard.
bool skipLidClosed = true;
// Take in a little of each confident unlock, so a new haircut or a pair
// of glasses does not need a new setup. Face ID does the same.
bool adapt = true;
double threshold() const;
static Settings load(const QString &path);
static QString livenessName(LivenessMode mode);
static QString strictnessName(Strictness strictness);
};
+206
View File
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "store.h"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <QSaveFile>
namespace
{
constexpr int FormatVersion = 1;
QByteArray packEmbedding(const Embedding &e)
{
QByteArray raw(reinterpret_cast<const char *>(e.data()), qsizetype(e.size() * sizeof(float)));
return raw.toBase64();
}
Embedding unpackEmbedding(const QString &b64)
{
const QByteArray raw = QByteArray::fromBase64(b64.toLatin1());
Embedding e;
if (raw.size() != qsizetype(Vision::EmbeddingSize * sizeof(float))) {
return e;
}
e.resize(Vision::EmbeddingSize);
memcpy(e.data(), raw.constData(), size_t(raw.size()));
return e;
}
} // namespace
int Identity::adaptiveCount() const
{
int n = 0;
for (const FaceSample &s : samples) {
n += s.pose == u"adaptive";
}
return n;
}
FaceStore::FaceStore(const QString &stateDir)
: m_dir(QDir(stateDir).filePath(QStringLiteral("users")))
{
}
QString FaceStore::fileFor(uint uid) const
{
return QDir(m_dir).filePath(QStringLiteral("%1.json").arg(uid));
}
QList<Identity> FaceStore::load(uint uid, QString *error) const
{
QList<Identity> faces;
QFile file(fileFor(uid));
if (!file.exists()) {
return faces;
}
if (!file.open(QIODevice::ReadOnly)) {
if (error) {
*error = file.errorString();
}
return faces;
}
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
if (!doc.isObject()) {
if (error) {
*error = parseError.errorString();
}
return faces;
}
const QJsonArray list = doc.object().value(u"faces").toArray();
for (const QJsonValue &v : list) {
const QJsonObject o = v.toObject();
Identity id;
id.id = o.value(u"id").toString();
id.name = o.value(u"name").toString();
id.created = qint64(o.value(u"created").toDouble());
id.enabled = o.value(u"enabled").toBool(true);
id.noseT = float(o.value(u"noseT").toDouble(0.55));
id.eyes = float(o.value(u"eyes").toDouble(0));
for (const QJsonValue &sv : o.value(u"samples").toArray()) {
const QJsonObject so = sv.toObject();
FaceSample s;
s.embedding = unpackEmbedding(so.value(u"e").toString());
s.pose = so.value(u"pose").toString();
s.time = qint64(so.value(u"t").toDouble());
if (!s.embedding.empty()) {
id.samples.append(s);
}
}
if (!id.id.isEmpty() && !id.samples.isEmpty()) {
faces.append(id);
}
}
return faces;
}
bool FaceStore::save(uint uid, const QList<Identity> &faces, QString *error) const
{
if (!QDir().mkpath(m_dir)) {
*error = QStringLiteral("cannot create %1").arg(m_dir);
return false;
}
QFile::setPermissions(m_dir, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
if (faces.isEmpty()) {
remove(uid);
return true;
}
QJsonArray list;
for (const Identity &id : faces) {
QJsonArray samples;
for (const FaceSample &s : id.samples) {
samples.append(QJsonObject{
{QStringLiteral("e"), QString::fromLatin1(packEmbedding(s.embedding))},
{QStringLiteral("pose"), s.pose},
{QStringLiteral("t"), double(s.time)},
});
}
list.append(QJsonObject{
{QStringLiteral("id"), id.id},
{QStringLiteral("name"), id.name},
{QStringLiteral("created"), double(id.created)},
{QStringLiteral("enabled"), id.enabled},
{QStringLiteral("noseT"), double(id.noseT)},
{QStringLiteral("eyes"), double(id.eyes)},
{QStringLiteral("samples"), samples},
});
}
const QJsonObject root{
{QStringLiteral("version"), FormatVersion},
{QStringLiteral("faces"), list},
};
QSaveFile file(fileFor(uid));
if (!file.open(QIODevice::WriteOnly)) {
*error = file.errorString();
return false;
}
file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
if (!file.commit()) {
*error = file.errorString();
return false;
}
return true;
}
bool FaceStore::remove(uint uid) const
{
return QFile::remove(fileFor(uid));
}
QString FaceStore::newId()
{
return QString::number(QRandomGenerator::system()->generate64() & 0xffffffffffffULL, 16);
}
FaceMatch FaceStore::bestMatch(const QList<Identity> &faces, const Embedding &e)
{
FaceMatch best;
for (int i = 0; i < faces.size(); ++i) {
const Identity &id = faces.at(i);
if (!id.enabled) {
continue;
}
for (int k = 0; k < id.samples.size(); ++k) {
const float s = Vision::similarity(id.samples.at(k).embedding, e);
if (s > best.score) {
best = {s, i, k};
}
}
}
return best;
}
bool FaceStore::adapt(Identity &identity, const Embedding &e, qint64 now)
{
// Something the samples already cover adds nothing but weight.
float closest = -1;
for (const FaceSample &s : std::as_const(identity.samples)) {
closest = std::max(closest, Vision::similarity(s.embedding, e));
}
if (closest >= 0.9f) {
return false;
}
if (identity.adaptiveCount() >= MaxAdaptive) {
for (qsizetype i = 0; i < identity.samples.size(); ++i) {
if (identity.samples.at(i).pose == u"adaptive") {
identity.samples.removeAt(i);
break;
}
}
}
identity.samples.append(FaceSample{e, QStringLiteral("adaptive"), now});
return true;
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The face data.
//
// One file per user under /var/lib/plasma-face-unlock/users, named after the
// numeric user id so a rename cannot hand one person's faces to another. Only
// root can read or write the directory. There are no pictures in it: every
// sample is the 128 numbers the recognizer made of one frame, and the frame
// itself was never written anywhere.
#pragma once
#include "vision.h"
#include <QList>
#include <QString>
struct FaceSample {
Embedding embedding;
// Which way the head pointed when it was taken: "center", one of the
// eight directions ("up", "up-right", ...), or "adaptive" for one taken
// in from a successful unlock.
QString pose;
qint64 time = 0;
};
struct Identity {
QString id;
QString name;
qint64 created = 0;
bool enabled = true;
// Where this person's nose sits for a level head (see HeadPose), and how
// open their eyes measure when they look at the camera. Both are what
// the attention check compares against.
float noseT = 0.55f;
float eyes = 0;
QList<FaceSample> samples;
int adaptiveCount() const;
};
struct FaceMatch {
float score = -1;
int identity = -1;
int sample = -1;
};
class FaceStore
{
public:
explicit FaceStore(const QString &stateDir);
QList<Identity> load(uint uid, QString *error = nullptr) const;
bool save(uint uid, const QList<Identity> &faces, QString *error) const;
bool remove(uint uid) const;
static QString newId();
// The best sample over every enabled identity.
static FaceMatch bestMatch(const QList<Identity> &faces, const Embedding &e);
// Adds what a confident unlock saw, if it adds anything, and keeps at most
// MaxAdaptive of those per identity (the oldest go first). The samples
// from the setup are never touched.
static bool adapt(Identity &identity, const Embedding &e, qint64 now);
static constexpr int MaxAdaptive = 12;
private:
QString fileFor(uint uid) const;
QString m_dir;
};
+219
View File
@@ -0,0 +1,219 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vision.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <opencv2/core/utils/logger.hpp>
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <cmath>
#include <numeric>
namespace
{
const char DetectorFile[] = "face_detection_yunet_2023mar.onnx";
const char RecognizerFile[] = "face_recognition_sface_2021dec.onnx";
// A face whose eyes are closer together than this is too far away for the
// recognizer to see detail in, and the liveness checks drown in noise.
constexpr float MinInterocular = 28.f;
cv::Point2f rotateAround(cv::Point2f p, cv::Point2f centre, float angle)
{
const float c = std::cos(angle);
const float s = std::sin(angle);
const cv::Point2f d = p - centre;
return {centre.x + d.x * c - d.y * s, centre.y + d.x * s + d.y * c};
}
} // namespace
float Face::interocular() const
{
return float(cv::norm(points[LeftEye] - points[RightEye]));
}
cv::Point2f Face::eyeMid() const
{
return (points[RightEye] + points[LeftEye]) * 0.5f;
}
cv::Point2f Face::mouthMid() const
{
return (points[RightMouth] + points[LeftMouth]) * 0.5f;
}
HeadPose estimatePose(const Face &face)
{
HeadPose pose;
const cv::Point2f eyes = face.points[LeftEye] - face.points[RightEye];
pose.roll = std::atan2(eyes.y, eyes.x);
// Level the face first, so a tilted head does not read as a turned one.
const cv::Point2f centre = face.eyeMid();
std::array<cv::Point2f, 5> p;
for (int i = 0; i < 5; ++i) {
p[i] = rotateAround(face.points[i], centre, -pose.roll);
}
const cv::Point2f eyeMid = (p[RightEye] + p[LeftEye]) * 0.5f;
const cv::Point2f mouthMid = (p[RightMouth] + p[LeftMouth]) * 0.5f;
const float iod = std::max(1.f, float(cv::norm(p[LeftEye] - p[RightEye])));
const float span = mouthMid.y - eyeMid.y;
if (span < 1.f) {
return pose;
}
const cv::Point2f nose = p[NoseTip];
const float t = (nose.y - eyeMid.y) / span;
const float midlineX = eyeMid.x + (mouthMid.x - eyeMid.x) * t;
// The eyes are reported person-right first, which is image-left on an
// unmirrored frame. A nose moving image-right is a head turning to the
// person's left.
pose.yaw = (nose.x - midlineX) / iod;
pose.noseT = t;
return pose;
}
float yawDegrees(float yaw)
{
return float(std::asin(std::clamp(yaw / 0.5f, -1.f, 1.f)) * 180.0 / M_PI);
}
FaceQuality assessQuality(const cv::Mat &bgr, const Face &face)
{
FaceQuality q;
q.tooSmall = face.interocular() < MinInterocular;
// The middle of the face, without hair and background at the edges.
const float iod = face.interocular();
const cv::Point2f c = (face.eyeMid() + face.mouthMid()) * 0.5f;
cv::Rect roi(cv::Point(int(c.x - iod), int(c.y - iod)), cv::Size(int(2 * iod), int(2 * iod)));
roi &= cv::Rect(0, 0, bgr.cols, bgr.rows);
if (roi.width < 8 || roi.height < 8) {
q.tooSmall = true;
return q;
}
cv::Mat grey;
cv::cvtColor(bgr(roi), grey, cv::COLOR_BGR2GRAY);
q.brightness = float(cv::mean(grey)[0]);
// Sharpness at a fixed scale, so a face far away and one up close are
// judged the same.
cv::Mat small, lap;
cv::resize(grey, small, cv::Size(96, 96), 0, 0, cv::INTER_AREA);
cv::Laplacian(small, lap, CV_32F);
cv::Scalar mean, stddev;
cv::meanStdDev(lap, mean, stddev);
q.sharpness = float(stddev[0] * stddev[0]);
q.tooDark = q.brightness < 40;
q.tooBright = q.brightness > 230;
q.blurry = q.sharpness < 12;
return q;
}
bool Vision::load(const QString &modelDir, QString *error)
{
// The new DNN engine in OpenCV 5 prints a warning for every network it
// loads about targets it does not support yet. Nothing here asks for one.
cv::utils::logging::setLogLevel(cv::utils::logging::LOG_LEVEL_ERROR);
const QDir dir(modelDir);
const QString detector = dir.filePath(QLatin1String(DetectorFile));
const QString recognizer = dir.filePath(QLatin1String(RecognizerFile));
for (const QString &f : {detector, recognizer}) {
if (!QFileInfo::exists(f)) {
*error = QStringLiteral("model missing: %1").arg(f);
return false;
}
}
try {
m_inputSize = cv::Size(640, 480);
m_detector = cv::FaceDetectorYN::create(QFile::encodeName(detector).toStdString(), "", m_inputSize, 0.75f, 0.3f, 20);
m_recognizer = cv::FaceRecognizerSF::create(QFile::encodeName(recognizer).toStdString(), "");
} catch (const cv::Exception &e) {
*error = QString::fromStdString(e.what());
m_detector.reset();
m_recognizer.reset();
return false;
}
return true;
}
std::vector<Face> Vision::detect(const cv::Mat &bgr)
{
std::vector<Face> result;
if (!m_detector || bgr.empty()) {
return result;
}
if (bgr.size() != m_inputSize) {
m_inputSize = bgr.size();
m_detector->setInputSize(m_inputSize);
}
cv::Mat rows;
try {
m_detector->detect(bgr, rows);
} catch (const cv::Exception &) {
return result;
}
for (int i = 0; i < rows.rows; ++i) {
const float *r = rows.ptr<float>(i);
Face f;
f.box = cv::Rect2f(r[0], r[1], r[2], r[3]);
for (int k = 0; k < 5; ++k) {
f.points[k] = cv::Point2f(r[4 + 2 * k], r[5 + 2 * k]);
}
f.score = r[14];
f.row = rows.row(i).clone();
result.push_back(std::move(f));
}
std::sort(result.begin(), result.end(), [](const Face &a, const Face &b) {
return a.box.area() > b.box.area();
});
return result;
}
Embedding Vision::embed(const cv::Mat &bgr, const Face &face)
{
Embedding out;
if (!m_recognizer) {
return out;
}
try {
cv::Mat aligned, feature;
m_recognizer->alignCrop(bgr, face.row, aligned);
m_recognizer->feature(aligned, feature);
feature = feature.reshape(1, 1);
if (feature.cols != EmbeddingSize) {
return out;
}
const double norm = cv::norm(feature);
if (norm <= 0) {
return out;
}
out.resize(EmbeddingSize);
for (int i = 0; i < EmbeddingSize; ++i) {
out[i] = float(feature.at<float>(0, i) / norm);
}
} catch (const cv::Exception &) {
out.clear();
}
return out;
}
float Vision::similarity(const Embedding &a, const Embedding &b)
{
if (a.size() != b.size() || a.empty()) {
return -1.f;
}
return std::inner_product(a.begin(), a.end(), b.begin(), 0.f);
}
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Finding a face and turning it into numbers.
//
// Two small networks from the OpenCV model zoo do the work. YuNet finds faces
// and five points on each (the eyes, the tip of the nose, the corners of the
// mouth). SFace turns an aligned crop of one face into 128 numbers, and two
// crops of the same person give numbers that point the same way. Both run on
// the CPU through OpenCV's own DNN module, in a few milliseconds each.
//
// Everything else here is plain geometry on those five points.
#pragma once
#include <QString>
#include <opencv2/core.hpp>
#include <opencv2/objdetect/face.hpp>
#include <array>
#include <vector>
// The five points, in the order YuNet reports them. "Right" and "left" are
// the person's own, so on an unmirrored picture the right eye is on the left.
enum Landmark {
RightEye = 0,
LeftEye = 1,
NoseTip = 2,
RightMouth = 3,
LeftMouth = 4,
};
struct Face {
cv::Rect2f box;
std::array<cv::Point2f, 5> points;
float score = 0;
// YuNet's own row, which the recognizer wants back for its alignment.
cv::Mat row;
float interocular() const;
cv::Point2f eyeMid() const;
cv::Point2f mouthMid() const;
};
// Where the head points, read off the five points alone.
//
// yaw is the nose's sideways offset from the line through the middle of the
// eyes and the middle of the mouth, in interocular distances. A nose sits
// about half an interocular distance in front of the face, so this is close to
// 0.5 * sin(head yaw). Positive when the head turns to the person's left.
//
// noseT is how far down the nose tip sits between the eye line (0) and the
// mouth line (1). It changes with pitch, but where it sits for a level head is
// different for every face, so it only means something next to the same
// person's own resting value.
struct HeadPose {
float roll = 0;
float yaw = 0;
float noseT = 0;
};
HeadPose estimatePose(const Face &face);
// Degrees, for people. The estimate is rough by nature.
float yawDegrees(float yaw);
struct FaceQuality {
float brightness = 0;
float sharpness = 0;
bool tooSmall = false;
bool tooDark = false;
bool tooBright = false;
bool blurry = false;
bool ok() const
{
return !tooSmall && !tooDark && !tooBright && !blurry;
}
};
FaceQuality assessQuality(const cv::Mat &bgr, const Face &face);
using Embedding = std::vector<float>;
class Vision
{
public:
bool load(const QString &modelDir, QString *error);
bool isLoaded() const
{
return m_detector && m_recognizer;
}
// Faces sorted by size, largest first.
std::vector<Face> detect(const cv::Mat &bgr);
// Unit length, so the similarity of two is their dot product.
Embedding embed(const cv::Mat &bgr, const Face &face);
static float similarity(const Embedding &a, const Embedding &b);
static constexpr int EmbeddingSize = 128;
private:
cv::Ptr<cv::FaceDetectorYN> m_detector;
cv::Ptr<cv::FaceRecognizerSF> m_recognizer;
cv::Size m_inputSize;
};
+160
View File
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// plasma-face-unlock-ctl: the shell code's way to the daemon.
//
// bash has no Unix sockets, so this sends one request and prints every
// message that comes back as one line of tab separated key=value pairs:
//
// event=frame score=0.71 yaw=3.2 ...
// event=result ok=true name=Felix score=0.74
//
// Lists (faces, cameras) come one line per entry, with event=item. Nothing
// in here is meant for people to read; the menu turns it into sentences.
//
// Exit status: 0 when the final result was ok, 1 when it was not, 2 when the
// daemon could not be reached.
#include "buildconfig.h"
#include <QCoreApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocalSocket>
#include <QStringList>
#include <cstdio>
namespace
{
QString flat(const QJsonValue &v)
{
QString s;
switch (v.type()) {
case QJsonValue::Bool:
s = v.toBool() ? QStringLiteral("true") : QStringLiteral("false");
break;
case QJsonValue::Double: {
const double d = v.toDouble();
s = (d == double(qint64(d)) && std::abs(d) < 1e15) ? QString::number(qint64(d)) : QString::number(d, 'f', 3);
break;
}
case QJsonValue::String:
s = v.toString();
break;
default:
s = QString::fromUtf8(QJsonDocument(v.toObject()).toJson(QJsonDocument::Compact));
break;
}
// Nothing a line or a field could be split on.
s.replace(QLatin1Char('\t'), QLatin1Char(' '));
s.replace(QLatin1Char('\n'), QLatin1Char(' '));
return s;
}
void printObject(const QJsonObject &o, const QString &event)
{
QStringList fields{QStringLiteral("event=") + event};
for (auto it = o.constBegin(); it != o.constEnd(); ++it) {
if (it.key() == u"event" || it.value().isArray() || it.key() == u"jpeg") {
continue;
}
fields << it.key() + QLatin1Char('=') + flat(it.value());
}
std::printf("%s\n", fields.join(QLatin1Char('\t')).toUtf8().constData());
std::fflush(stdout);
}
int usage()
{
std::fprintf(stderr,
"usage: plasma-face-unlock-ctl [--socket PATH] COMMAND\n"
" hello | status | cameras | list | watch | unlocked\n"
" verify [USER] [PURPOSE] | test\n"
" remove ID | rename ID NAME | enable ID | disable ID | clear\n");
return 2;
}
} // namespace
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
QStringList args = app.arguments().mid(1);
QString socketPath = QStringLiteral(PFU_SOCKET);
if (args.size() >= 2 && args.first() == u"--socket") {
socketPath = args.at(1);
args = args.mid(2);
}
if (const QByteArray env = qgetenv("PFU_SOCKET"); !env.isEmpty()) {
socketPath = QString::fromLocal8Bit(env);
}
if (args.isEmpty()) {
return usage();
}
const QString cmd = args.takeFirst();
QJsonObject request{{QStringLiteral("cmd"), cmd}};
if (cmd == u"verify") {
if (!args.isEmpty()) {
request.insert(QStringLiteral("user"), args.takeFirst());
}
request.insert(QStringLiteral("purpose"), args.isEmpty() ? QStringLiteral("other") : args.takeFirst());
} else if (cmd == u"test") {
request = {{QStringLiteral("cmd"), QStringLiteral("verify")}, {QStringLiteral("purpose"), QStringLiteral("test")}, {QStringLiteral("verbose"), true}};
} else if (cmd == u"remove" || cmd == u"enable" || cmd == u"disable") {
if (args.isEmpty()) {
return usage();
}
request.insert(QStringLiteral("id"), args.takeFirst());
} else if (cmd == u"rename") {
if (args.size() < 2) {
return usage();
}
request.insert(QStringLiteral("id"), args.takeFirst());
request.insert(QStringLiteral("name"), args.join(QLatin1Char(' ')));
} else if (!QStringList{QStringLiteral("hello"), QStringLiteral("status"), QStringLiteral("cameras"), QStringLiteral("list"), QStringLiteral("watch"),
QStringLiteral("unlocked"), QStringLiteral("clear")}
.contains(cmd)) {
return usage();
}
QLocalSocket socket;
socket.connectToServer(socketPath);
if (!socket.waitForConnected(5000)) {
std::printf("event=result\tok=false\treason=unreachable\tmessage=%s\n", qPrintable(socket.errorString()));
return 2;
}
socket.write(QJsonDocument(request).toJson(QJsonDocument::Compact) + '\n');
socket.flush();
QByteArray buffer;
// Changing faces can wait on somebody typing their password into the
// polkit dialog, and "watch" waits forever.
while (socket.state() == QLocalSocket::ConnectedState || socket.bytesAvailable() > 0) {
if (socket.bytesAvailable() == 0 && !socket.waitForReadyRead(-1)) {
break;
}
buffer += socket.readAll();
qsizetype nl;
while ((nl = buffer.indexOf('\n')) >= 0) {
const QJsonObject o = QJsonDocument::fromJson(buffer.left(nl)).object();
buffer.remove(0, nl + 1);
const QString event = o.value(u"event").toString();
if (event == u"result") {
for (const QJsonValue &f : o.value(u"faces").toArray()) {
printObject(f.toObject(), QStringLiteral("item"));
}
for (const QJsonValue &c : o.value(u"cameras").toArray()) {
printObject(c.toObject(), QStringLiteral("item"));
}
printObject(o, event);
return o.value(u"ok").toBool() ? 0 : 1;
}
printObject(o, event);
}
}
std::printf("event=result\tok=false\treason=disconnected\n");
return 1;
}
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "agentlink.h"
#include <QFile>
#include <QJsonDocument>
#include <QTimer>
#include <sys/socket.h>
#include <sys/stat.h>
namespace
{
bool ownedBy(const QString &path, uid_t uid, bool socket)
{
struct stat st;
if (::lstat(QFile::encodeName(path).constData(), &st) != 0 || st.st_uid != uid) {
return false;
}
return socket ? S_ISSOCK(st.st_mode) : S_ISDIR(st.st_mode);
}
} // namespace
AgentLink::AgentLink(uid_t uid, QObject *parent)
: QObject(parent)
, m_uid(uid)
{
const QString dir = QStringLiteral("/run/user/%1/plasma-face-unlock").arg(uid);
const QString path = dir + QStringLiteral("/agent.socket");
if (!ownedBy(dir, uid, false) || !ownedBy(path, uid, true)) {
// No agent in that session, or not one of this user's making.
QTimer::singleShot(0, this, &QObject::deleteLater);
return;
}
connect(&m_socket, &QLocalSocket::connected, this, [this] {
ucred cred{};
socklen_t len = sizeof(cred);
if (::getsockopt(int(m_socket.socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0 || cred.uid != m_uid) {
m_socket.abort();
deleteLater();
return;
}
m_trusted = true;
flush();
});
connect(&m_socket, &QLocalSocket::errorOccurred, this, [this] {
deleteLater();
});
m_socket.connectToServer(path);
// However the scan ends, this does not outlive it by much.
QTimer::singleShot(60 * 1000, this, &QObject::deleteLater);
}
void AgentLink::send(const QJsonObject &event)
{
m_queue.append(QJsonDocument(event).toJson(QJsonDocument::Compact) + '\n');
flush();
}
void AgentLink::finish()
{
m_finishing = true;
flush();
}
void AgentLink::flush()
{
if (!m_trusted) {
return;
}
for (const QByteArray &line : std::as_const(m_queue)) {
m_socket.write(line);
}
m_queue.clear();
m_socket.flush();
if (m_finishing) {
m_socket.disconnectFromServer();
deleteLater();
}
}
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Telling a user's session about a scan it did not start itself (sudo, an
// admin prompt, a test from the menu), so the bubble can show it.
//
// The agent listens on /run/user/UID/plasma-face-unlock/agent.socket. That is
// a place the user controls, so nothing is taken for granted: the socket has
// to belong to the user, and so does the process that answers on it, checked
// by the kernel before a single byte is written. What is written is only
// where a scan is ("started", "success", ...), never anything about the face.
#pragma once
#include <QJsonObject>
#include <QList>
#include <QLocalSocket>
#include <QObject>
#include <sys/types.h>
class AgentLink : public QObject
{
Q_OBJECT
public:
AgentLink(uid_t uid, QObject *parent);
void send(const QJsonObject &event);
// Sends what is still queued, then goes away.
void finish();
private:
void flush();
uid_t m_uid;
QLocalSocket m_socket;
QList<QByteArray> m_queue;
bool m_trusted = false;
bool m_finishing = false;
};
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "client.h"
#include <QJsonDocument>
#include <sys/socket.h>
namespace
{
// A request is a line of JSON a few hundred bytes long. Anything that keeps
// talking without a newline for this long is not a client.
constexpr qsizetype MaxLine = 64 * 1024;
} // namespace
Client::Client(QLocalSocket *socket, QObject *parent)
: QObject(parent)
, m_socket(socket)
{
m_socket->setParent(this);
ucred cred{};
socklen_t len = sizeof(cred);
if (::getsockopt(int(m_socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &cred, &len) == 0) {
m_uid = cred.uid;
m_pid = cred.pid;
m_known = true;
}
connect(m_socket, &QLocalSocket::readyRead, this, &Client::readLines);
connect(m_socket, &QLocalSocket::disconnected, this, [this] {
if (!m_gone) {
m_gone = true;
Q_EMIT disconnected(this);
}
});
}
Client::~Client() = default;
void Client::readLines()
{
m_buffer += m_socket->readAll();
if (m_buffer.size() > MaxLine && !m_buffer.contains('\n')) {
close();
return;
}
qsizetype nl;
while ((nl = m_buffer.indexOf('\n')) >= 0) {
const QByteArray line = m_buffer.left(nl).trimmed();
m_buffer.remove(0, nl + 1);
if (line.isEmpty()) {
continue;
}
const QJsonDocument doc = QJsonDocument::fromJson(line);
if (!doc.isObject()) {
send({{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("bad-request")}});
continue;
}
Q_EMIT request(this, doc.object());
}
}
void Client::send(const QJsonObject &message)
{
if (m_gone || m_socket->state() != QLocalSocket::ConnectedState) {
return;
}
m_socket->write(QJsonDocument(message).toJson(QJsonDocument::Compact) + '\n');
m_socket->flush();
}
void Client::close()
{
if (m_socket->state() == QLocalSocket::ConnectedState) {
m_socket->flush();
m_socket->disconnectFromServer();
}
}
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// One connection to the daemon.
//
// The kernel says who is on the other end (SO_PEERCRED), and that is the only
// thing any decision here is based on. Nothing a client writes about itself is
// taken on trust.
#pragma once
#include <QJsonObject>
#include <QLocalSocket>
#include <QObject>
#include <sys/types.h>
class Client : public QObject
{
Q_OBJECT
public:
Client(QLocalSocket *socket, QObject *parent);
~Client() override;
uid_t uid() const
{
return m_uid;
}
pid_t pid() const
{
return m_pid;
}
bool credentialsKnown() const
{
return m_known;
}
void send(const QJsonObject &message);
void close();
// What this connection is for, once it has said so. A connection makes
// one request; "watch", "verify" and "enroll" keep it open.
QString role;
Q_SIGNALS:
void request(Client *client, const QJsonObject &message);
void disconnected(Client *client);
private:
void readLines();
QLocalSocket *m_socket;
QByteArray m_buffer;
uid_t m_uid = uid_t(-1);
pid_t m_pid = 0;
bool m_known = false;
bool m_gone = false;
};
+270
View File
@@ -0,0 +1,270 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "enrolljob.h"
#include "camera.h"
#include "liveness.h"
#include "vision.h"
#include <QDateTime>
#include <QElapsedTimer>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <array>
#include <cmath>
namespace
{
// A head turned about 15 degrees moves the nose this far (in eye
// distances), sideways for a turn and up or down for a nod. The ring is drawn
// in these units, so 1.0 is a comfortable turn.
constexpr float TurnUnit = 0.13f;
// Far enough out to count for a direction.
constexpr float CaptureAt = 0.8f;
// Straight enough to count as looking straight ahead.
constexpr float StraightYaw = 0.06f;
constexpr qint64 SampleSpacingMs = 150;
constexpr qint64 CentreSpacingMs = 250;
constexpr qint64 PreviewEveryMs = 66;
constexpr qint64 GiveUpAfterMs = 120 * 1000;
constexpr int PreviewWidth = 480;
float median(QList<float> v)
{
if (v.isEmpty()) {
return 0;
}
std::sort(v.begin(), v.end());
return v.at(v.size() / 2);
}
} // namespace
EnrollJob::EnrollJob(Vision *vision, uid_t uid, const Settings &settings, const QString &name)
: Job(vision, uid)
, m_settings(settings)
, m_name(name)
{
}
QString EnrollJob::sectorName(int sector)
{
static const char *names[] = {"up", "up-right", "right", "down-right", "down", "down-left", "left", "up-left"};
return QString::fromLatin1(names[((sector % 8) + 8) % 8]);
}
void EnrollJob::sendPreview(const cv::Mat &frame, const Face *face)
{
cv::Mat mirrored, small;
cv::flip(frame, mirrored, 1);
const double scale = double(PreviewWidth) / mirrored.cols;
cv::resize(mirrored, small, cv::Size(), scale, scale, cv::INTER_AREA);
std::vector<uchar> jpeg;
cv::imencode(".jpg", small, jpeg, {cv::IMWRITE_JPEG_QUALITY, 72});
QJsonObject msg{
{QStringLiteral("event"), QStringLiteral("frame")},
{QStringLiteral("w"), small.cols},
{QStringLiteral("h"), small.rows},
{QStringLiteral("jpeg"), QString::fromLatin1(QByteArray(reinterpret_cast<const char *>(jpeg.data()), qsizetype(jpeg.size())).toBase64())},
};
if (face) {
// In the mirrored picture, as a share of its size.
const float w = float(frame.cols);
const float h = float(frame.rows);
msg.insert(QStringLiteral("face"),
QJsonObject{
{QStringLiteral("x"), double((w - face->box.x - face->box.width) / w)},
{QStringLiteral("y"), double(face->box.y / h)},
{QStringLiteral("w"), double(face->box.width / w)},
{QStringLiteral("h"), double(face->box.height / h)},
});
}
Q_EMIT event(msg);
}
void EnrollJob::run()
{
Camera camera;
QString error;
if (!camera.open(m_settings.camera, &error)) {
result = {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("camera")},
{QStringLiteral("message"), error}};
return;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("started")}, {QStringLiteral("camera"), camera.description()}});
QElapsedTimer clock;
clock.start();
QList<float> centreNoseT;
QList<float> centreEyes;
QList<FaceSample> samples;
int centreCount = 0;
qint64 lastCentreSample = -CentreSpacingMs;
bool centreDone = false;
float restingNoseT = 0.55f;
std::array<int, 8> counts{};
std::array<qint64, 8> lastAt;
lastAt.fill(-SampleSpacingMs);
QString lastHint;
const auto hint = [&](const QString &what) {
if (what != lastHint) {
lastHint = what;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("hint")}, {QStringLiteral("hint"), what}});
}
};
const auto sectorsDone = [&] {
return int(std::count(counts.begin(), counts.end(), SamplesPerSector));
};
cv::Mat frame;
int readFailures = 0;
while (!m_cancel && clock.elapsed() < GiveUpAfterMs) {
double t = 0;
if (!camera.read(frame, &t)) {
if (++readFailures > 10) {
result = {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("camera")},
{QStringLiteral("message"), QStringLiteral("the camera stopped delivering frames")}};
return;
}
continue;
}
readFailures = 0;
const qint64 now = clock.elapsed();
const std::vector<Face> faces = m_vision->detect(frame);
const Face *face = faces.empty() ? nullptr : &faces.front();
if (now - m_lastPreview >= PreviewEveryMs) {
m_lastPreview = now;
sendPreview(frame, face);
}
if (!face) {
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("pose")}, {QStringLiteral("face"), false}});
hint(QStringLiteral("no-face"));
continue;
}
// Somebody else in the picture, close enough to be taken for the
// person setting up.
if (faces.size() > 1 && faces[1].box.area() > 0.5f * face->box.area()) {
hint(QStringLiteral("one-face"));
continue;
}
const FaceQuality quality = assessQuality(frame, *face);
if (!quality.ok()) {
hint(quality.tooSmall ? QStringLiteral("closer")
: quality.tooDark ? QStringLiteral("light")
: quality.tooBright ? QStringLiteral("bright")
: QStringLiteral("still"));
continue;
}
const HeadPose pose = estimatePose(*face);
if (!centreDone) {
const bool straight = std::abs(pose.yaw) <= StraightYaw;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("pose")},
{QStringLiteral("face"), true},
{QStringLiteral("phase"), QStringLiteral("center")},
{QStringLiteral("x"), double(-pose.yaw / TurnUnit)},
{QStringLiteral("y"), 0.0}});
if (!straight) {
hint(QStringLiteral("straight"));
continue;
}
hint(QStringLiteral("hold"));
centreNoseT.append(pose.noseT);
const EyeSample eyes = measureEyes(frame, *face);
if (eyes.valid) {
centreEyes.append(eyes.openness);
}
if (centreCount < CentreSamples && now - lastCentreSample >= CentreSpacingMs) {
const Embedding e = m_vision->embed(frame, *face);
if (!e.empty()) {
samples.append({e, QStringLiteral("center"), QDateTime::currentSecsSinceEpoch()});
++centreCount;
lastCentreSample = now;
}
}
if (centreCount >= CentreSamples && centreNoseT.size() >= 6) {
restingNoseT = median(centreNoseT);
centreDone = true;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("captured")}, {QStringLiteral("pose"), QStringLiteral("center")}});
hint(QStringLiteral("circle"));
}
continue;
}
// Screen directions in the mirrored preview: turning to one's own left
// moves the face to the left of the screen, looking up moves it up.
const float x = -pose.yaw / TurnUnit;
const float y = -(pose.noseT - restingNoseT) / TurnUnit;
const float reach = std::hypot(x, y);
double angle = std::atan2(x, y) * 180.0 / M_PI;
if (angle < 0) {
angle += 360;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("pose")},
{QStringLiteral("face"), true},
{QStringLiteral("phase"), QStringLiteral("circle")},
{QStringLiteral("x"), double(x)},
{QStringLiteral("y"), double(y)},
{QStringLiteral("angle"), angle},
{QStringLiteral("reach"), double(reach)}});
if (reach >= CaptureAt) {
const int sector = int(std::lround(angle / 45.0)) % 8;
if (counts[sector] < SamplesPerSector && now - lastAt[sector] >= SampleSpacingMs) {
const Embedding e = m_vision->embed(frame, *face);
if (!e.empty()) {
samples.append({e, sectorName(sector), QDateTime::currentSecsSinceEpoch()});
lastAt[sector] = now;
if (++counts[sector] == SamplesPerSector) {
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("captured")},
{QStringLiteral("pose"), sectorName(sector)},
{QStringLiteral("sector"), sector},
{QStringLiteral("done"), sectorsDone()}});
}
}
}
}
if (sectorsDone() == 8 || (m_finishEarly && sectorsDone() >= SectorsForEarlyFinish)) {
break;
}
}
if (m_cancel) {
result = {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), false}, {QStringLiteral("reason"), QStringLiteral("cancelled")}};
return;
}
if (!centreDone || sectorsDone() < SectorsForEarlyFinish) {
result = {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), false}, {QStringLiteral("reason"), QStringLiteral("timeout")}};
return;
}
identity.id = FaceStore::newId();
identity.name = m_name;
identity.created = QDateTime::currentSecsSinceEpoch();
identity.noseT = restingNoseT;
identity.eyes = median(centreEyes);
identity.samples = samples;
result = {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), true},
{QStringLiteral("reason"), QStringLiteral("ok")},
{QStringLiteral("id"), identity.id},
{QStringLiteral("name"), identity.name},
{QStringLiteral("samples"), int(samples.size())}};
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Setting up a face.
//
// Like the phone: look straight at the camera first, then move the head
// around in a circle while the ring fills up. The centre gives the samples
// most unlocks will match against and the level-head measurements the
// attention check needs; the eight directions around it are what still
// matches when somebody glances at the screen from the side.
#pragma once
#include "job.h"
#include "settings.h"
#include "store.h"
class EnrollJob : public Job
{
Q_OBJECT
public:
EnrollJob(Vision *vision, uid_t uid, const Settings &settings, const QString &name);
void run() override;
QString kind() const override
{
return QStringLiteral("enroll");
}
// Stop as soon as enough of the circle is done.
void finishEarly()
{
m_finishEarly = true;
}
// Filled when the setup completed.
Identity identity;
// The eight directions, clockwise from straight up, as seen in the
// mirrored preview.
static QString sectorName(int sector);
static constexpr int SamplesPerSector = 2;
static constexpr int CentreSamples = 3;
static constexpr int SectorsForEarlyFinish = 4;
private:
void sendPreview(const cv::Mat &frame, const struct Face *face);
Settings m_settings;
QString m_name;
std::atomic_bool m_finishEarly = false;
qint64 m_lastPreview = -1000;
};
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Work that holds the camera: a scan or a setup. Only one runs at a time,
// on a thread of its own so the socket stays responsive (a cancel has to get
// through while a frame is being processed).
#pragma once
#include <QJsonObject>
#include <QObject>
#include <atomic>
#include <sys/types.h>
class Vision;
class Job : public QObject
{
Q_OBJECT
public:
Job(Vision *vision, uid_t uid)
: m_vision(vision)
, m_uid(uid)
{
}
virtual void run() = 0;
virtual QString kind() const = 0;
void cancel()
{
m_cancel = true;
}
bool cancelled() const
{
return m_cancel;
}
uid_t uid() const
{
return m_uid;
}
QJsonObject result;
Q_SIGNALS:
// Progress for whoever asked, and for the bubble.
void event(const QJsonObject &event);
protected:
Vision *m_vision;
uid_t m_uid;
std::atomic_bool m_cancel = false;
};
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// plasma-face-unlockd: the part that runs as root.
//
// It owns the camera and the face data, and it is the only thing that does.
// Everything else (the lock screen agent, sudo, the setup window, the menu)
// asks it over one socket. systemd starts it on the first connection and it
// exits again after a minute with nothing to do, so most of the time it is not
// running at all.
#include "server.h"
#include "buildconfig.h"
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QSocketNotifier>
#include <csignal>
#include <sys/signalfd.h>
#include <sys/stat.h>
#include <unistd.h>
namespace
{
// sd_listen_fds(), without linking libsystemd for one function.
int systemdSocket()
{
const QByteArray pid = qgetenv("LISTEN_PID");
const QByteArray fds = qgetenv("LISTEN_FDS");
if (pid.isEmpty() || fds.isEmpty() || pid.toLongLong() != getpid() || fds.toInt() < 1) {
return -1;
}
qunsetenv("LISTEN_PID");
qunsetenv("LISTEN_FDS");
qunsetenv("LISTEN_FDNAMES");
return 3;
}
// SIGTERM from systemd is the normal way this ends. The camera thread is
// cancelled and joined on the way out rather than being cut off mid-frame.
void quitOnSignals(QCoreApplication &app)
{
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, nullptr);
const int fd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
if (fd < 0) {
return;
}
auto *notifier = new QSocketNotifier(fd, QSocketNotifier::Read, &app);
QObject::connect(notifier, &QSocketNotifier::activated, &app, [fd] {
signalfd_siginfo info;
while (read(fd, &info, sizeof(info)) > 0) {
}
QCoreApplication::quit();
});
}
} // namespace
int main(int argc, char **argv)
{
// Face data and state are for root's eyes only, whatever creates them.
umask(077);
QCoreApplication app(argc, argv);
app.setApplicationName(QStringLiteral("plasma-face-unlockd"));
app.setApplicationVersion(QStringLiteral(PFU_VERSION));
qSetMessagePattern(QStringLiteral("%{if-warning}warning: %{endif}%{if-critical}error: %{endif}%{message}"));
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("Face unlock daemon for KDE Plasma"));
parser.addHelpOption();
parser.addVersionOption();
const QCommandLineOption socketOpt(QStringLiteral("socket"), QStringLiteral("Listen here instead of the system socket."), QStringLiteral("path"),
QStringLiteral(PFU_SOCKET));
const QCommandLineOption stateOpt(QStringLiteral("state-dir"), QStringLiteral("Where face data is kept."), QStringLiteral("dir"),
QStringLiteral(PFU_STATEDIR));
const QCommandLineOption configOpt(QStringLiteral("config"), QStringLiteral("The settings file."), QStringLiteral("file"), QStringLiteral(PFU_CONFIG));
const QCommandLineOption modelOpt(QStringLiteral("models"), QStringLiteral("Where the networks are."), QStringLiteral("dir"),
QStringLiteral(PFU_MODELDIR));
const QCommandLineOption idleOpt(QStringLiteral("idle-exit"), QStringLiteral("Exit after this many idle seconds (0: never)."), QStringLiteral("seconds"));
parser.addOptions({socketOpt, stateOpt, configOpt, modelOpt, idleOpt});
parser.process(app);
ServerOptions options;
options.socketPath = parser.value(socketOpt);
options.stateDir = parser.value(stateOpt);
options.configPath = parser.value(configOpt);
options.modelDir = parser.value(modelOpt);
options.systemdFd = systemdSocket();
// Started by the socket: go away again when there is nothing to do.
// Started by hand (development): stay.
options.idleSeconds = parser.isSet(idleOpt) ? parser.value(idleOpt).toInt() : (options.systemdFd >= 0 ? 60 : 0);
options.askPolkit = geteuid() == 0;
if (!options.askPolkit) {
qInfo("not running as root: face changes are not checked with polkit (development only)");
}
quitOnSignals(app);
Server server(options);
QString error;
if (!server.start(&error)) {
qCritical("cannot listen: %s", qPrintable(error));
return 1;
}
return app.exec();
}
+115
View File
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "polkit.h"
#include "system.h"
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusMetaType>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QLoggingCategory>
namespace
{
// (sa{sv}): the subject, "unix-process" with its pid, start time and uid.
struct Subject {
QString kind;
QVariantMap details;
};
// (bba{ss}): authorized, challenge, details.
struct Result {
bool authorized = false;
bool challenge = false;
QMap<QString, QString> details;
};
} // namespace
Q_DECLARE_METATYPE(Subject)
Q_DECLARE_METATYPE(Result)
namespace
{
QDBusArgument &operator<<(QDBusArgument &arg, const Subject &s)
{
arg.beginStructure();
arg << s.kind << s.details;
arg.endStructure();
return arg;
}
const QDBusArgument &operator>>(const QDBusArgument &arg, Subject &s)
{
arg.beginStructure();
arg >> s.kind >> s.details;
arg.endStructure();
return arg;
}
QDBusArgument &operator<<(QDBusArgument &arg, const Result &r)
{
arg.beginStructure();
arg << r.authorized << r.challenge << r.details;
arg.endStructure();
return arg;
}
const QDBusArgument &operator>>(const QDBusArgument &arg, Result &r)
{
arg.beginStructure();
arg >> r.authorized >> r.challenge >> r.details;
arg.endStructure();
return arg;
}
void registerTypes()
{
static bool done = false;
if (!done) {
qDBusRegisterMetaType<Subject>();
qDBusRegisterMetaType<Result>();
qDBusRegisterMetaType<QMap<QString, QString>>();
done = true;
}
}
constexpr quint32 AllowUserInteraction = 1;
// Long enough to find the dialog and type a password.
constexpr int TimeoutMs = 5 * 60 * 1000;
} // namespace
namespace Polkit
{
void checkAuthorization(pid_t pid, uid_t uid, const char *action, QObject *context, std::function<void(bool)> done)
{
registerTypes();
Subject subject;
subject.kind = QStringLiteral("unix-process");
subject.details.insert(QStringLiteral("pid"), QVariant::fromValue(quint32(pid)));
subject.details.insert(QStringLiteral("start-time"), QVariant::fromValue(quint64(System::processStartTime(pid))));
subject.details.insert(QStringLiteral("uid"), QVariant::fromValue(qint32(uid)));
QDBusMessage msg = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.PolicyKit1"),
QStringLiteral("/org/freedesktop/PolicyKit1/Authority"),
QStringLiteral("org.freedesktop.PolicyKit1.Authority"),
QStringLiteral("CheckAuthorization"));
msg << QVariant::fromValue(subject) << QString::fromLatin1(action) << QVariant::fromValue(QMap<QString, QString>())
<< AllowUserInteraction << QString();
const QDBusPendingCall call = QDBusConnection::systemBus().asyncCall(msg, TimeoutMs);
auto *watcher = new QDBusPendingCallWatcher(call, context);
QObject::connect(watcher, &QDBusPendingCallWatcher::finished, context, [watcher, done] {
watcher->deleteLater();
const QDBusPendingReply<Result> reply = *watcher;
if (reply.isError()) {
qWarning("polkit: %s", qPrintable(reply.error().message()));
done(false);
return;
}
done(reply.value().authorized);
});
}
} // namespace Polkit
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Asking polkit whether a process may change face data.
//
// 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.
#pragma once
#include <QObject>
#include <functional>
#include <sys/types.h>
namespace Polkit
{
inline constexpr char ManageAction[] = "io.github.loonixtools.plasma-face-unlock.manage";
// Calls done(true) when the process may go ahead. Interactive: this can take
// as long as it takes somebody to type a password.
void checkAuthorization(pid_t pid, uid_t uid, const char *action, QObject *context, std::function<void(bool)> done);
} // namespace Polkit
+316
View File
@@ -0,0 +1,316 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "scanjob.h"
#include "camera.h"
#include "liveness.h"
#include "vision.h"
#include <QElapsedTimer>
#include <QJsonArray>
#include <cmath>
namespace
{
// Two frames have to match. One could be a fluke of the light.
constexpr int MatchesNeeded = 2;
// Once somebody has matched, every frame spent on the recognizer is a frame
// the blink check does not see, and a blink is only a few frames long. So
// after a match the recognizer only runs on every fourth frame, which is
// still often enough to notice a different face.
constexpr int RecheckEvery = 4;
// A face that jumps further than this between two frames is treated as a
// different face, and everything learned about the previous one is dropped.
constexpr float JumpLimit = 0.6f;
// How long the deny cues watch before "light" lets anybody in.
constexpr int LightFramesNeeded = 5;
// Heavy: after a match, how long to wait for a sign of life before asking
// for one, and how much longer to wait once asked.
constexpr qint64 AskForLifeAfterMs = 1200;
constexpr qint64 ExtraTimeMs = 2500;
// Attention: a head turned further than this is not looking at the screen.
constexpr float MaxYawDegrees = 25;
constexpr float MaxPitchT = 0.16f;
double median(QList<float> v)
{
if (v.isEmpty()) {
return 0;
}
std::sort(v.begin(), v.end());
return v.at(v.size() / 2);
}
} // namespace
ScanJob::ScanJob(Vision *vision, uid_t uid, const Settings &settings, const QList<Identity> &faces, const QString &purpose, bool verbose)
: Job(vision, uid)
, m_settings(settings)
, m_faces(faces)
, m_purpose(purpose)
, m_verbose(verbose)
{
}
void ScanJob::finish(bool ok, const QString &reason, const QJsonObject &extra)
{
result = extra;
result.insert(QStringLiteral("event"), QStringLiteral("result"));
result.insert(QStringLiteral("ok"), ok);
result.insert(QStringLiteral("reason"), reason);
}
void ScanJob::hint(const QString &what)
{
if (m_hinted.contains(what)) {
return;
}
m_hinted.insert(what);
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("hint")}, {QStringLiteral("hint"), what}});
}
void ScanJob::run()
{
Camera camera;
QString error;
if (!camera.open(m_settings.camera, &error)) {
finish(false, QStringLiteral("camera"), {{QStringLiteral("message"), error}});
return;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("started")}, {QStringLiteral("camera"), camera.description()}});
// What the attention check compares against: this person's own level-head
// nose position and open-eye measurement, from the setup.
QList<float> noseTs, eyes;
for (const Identity &id : std::as_const(m_faces)) {
if (id.enabled) {
noseTs.append(id.noseT);
if (id.eyes > 0) {
eyes.append(id.eyes);
}
}
}
const float restingNoseT = float(median(noseTs));
const float openEyes = float(median(eyes));
const double threshold = m_settings.threshold();
const LivenessMode mode = m_settings.liveness;
QElapsedTimer clock;
clock.start();
qint64 deadline = qint64(m_settings.scanSeconds) * 1000;
bool extended = false;
LivenessAnalyzer live;
int matched = 0;
int mismatched = 0;
int attentionMisses = 0;
int qualityMisses = 0;
int sinceCheck = 0;
bool lastCheckMatched = false;
bool sawFace = false;
int readFailures = 0;
qint64 firstMatchAt = -1;
cv::Point2f lastCentre(-1, -1);
qint64 lastFaceAt = -1;
float bestSeen = -1;
const auto forgetMatch = [&] {
matched = 0;
lastCheckMatched = false;
firstMatchAt = -1;
matchedIdentity = -1;
matchedScore = 0;
matchedEmbedding.clear();
};
cv::Mat frame;
while (!m_cancel) {
const qint64 elapsed = clock.elapsed();
if (elapsed > deadline) {
// Heavy has matched and is only waiting for a sign of life: give
// the person the time to blink that the hint just asked for.
if (mode == LivenessMode::Heavy && matched >= MatchesNeeded && !extended) {
deadline += ExtraTimeMs;
extended = true;
continue;
}
break;
}
double t = 0;
if (!camera.read(frame, &t)) {
if (++readFailures > 10) {
finish(false, QStringLiteral("camera"), {{QStringLiteral("message"), QStringLiteral("the camera stopped delivering frames")}});
return;
}
continue;
}
readFailures = 0;
const std::vector<Face> faces = m_vision->detect(frame);
if (faces.empty()) {
continue;
}
const Face &face = faces.front();
const float iod = face.interocular();
const cv::Point2f centre = (face.eyeMid() + face.mouthMid()) * 0.5f;
// A face that appeared somewhere else, or after a gap, may be a
// different one. Whatever was concluded about the last one goes.
const bool jumped = lastCentre.x >= 0 && float(cv::norm(centre - lastCentre)) > JumpLimit * iod;
const bool gap = lastFaceAt >= 0 && elapsed - lastFaceAt > 400;
if (jumped || gap) {
live.reset();
forgetMatch();
}
lastCentre = centre;
lastFaceAt = elapsed;
if (!sawFace) {
sawFace = true;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("face")}});
}
const FaceQuality quality = assessQuality(frame, face);
const LivenessFrame lf = measureFrame(frame, face, t);
live.add(lf);
const LivenessReading reading = live.reading();
if (mode != LivenessMode::Off && reading.denied) {
finish(false, QStringLiteral("spoof"), {{QStringLiteral("cue"), reading.deniedBy}});
return;
}
if (!quality.ok()) {
++qualityMisses;
if (quality.tooSmall) {
hint(QStringLiteral("closer"));
} else if (quality.tooDark) {
hint(QStringLiteral("light"));
}
continue;
}
// Whether this face looks at the screen with open eyes. Only asked of
// a face that matches: a stranger is a stranger whichever way they
// look, and should be counted as one.
const auto attentive = [&] {
if (!m_settings.attention) {
return true;
}
const bool facing = std::abs(yawDegrees(lf.pose.yaw)) <= MaxYawDegrees
&& (noseTs.isEmpty() || std::abs(lf.pose.noseT - restingNoseT) <= MaxPitchT);
// Eyes that measure as closed, next to how open they measured at
// setup. Skipped for eyes the measure does not work on.
const bool eyesOpen = !lf.eyes.valid || openEyes < 0.15f || lf.eyes.openness >= 0.45f * openEyes;
if (!facing) {
hint(QStringLiteral("look"));
}
return facing && eyesOpen;
};
float score = -1;
bool checked = false;
if (matched < MatchesNeeded || ++sinceCheck >= RecheckEvery) {
sinceCheck = 0;
checked = true;
const Embedding e = m_vision->embed(frame, face);
const FaceMatch m = FaceStore::bestMatch(m_faces, e);
score = m.score;
bestSeen = std::max(bestSeen, m.score);
if (m.identity >= 0 && m.score >= threshold) {
if (!attentive()) {
++attentionMisses;
continue;
}
++matched;
lastCheckMatched = true;
if (firstMatchAt < 0) {
firstMatchAt = elapsed;
}
if (m.score > matchedScore) {
matchedScore = m.score;
matchedIdentity = m.identity;
matchedEmbedding = e;
}
} else {
++mismatched;
// The face that matched before does not match now. Whatever
// it did to look alive was done by somebody else's face.
if (lastCheckMatched) {
live.reset();
forgetMatch();
}
lastCheckMatched = false;
}
} else if (!attentive()) {
// Between checks: a matched face that looks away or closes its
// eyes (a blink does both for a moment) is not counted as looking.
++attentionMisses;
continue;
}
if (m_verbose) {
QJsonObject info{
{QStringLiteral("event"), QStringLiteral("frame")},
{QStringLiteral("t"), qint64(t)},
{QStringLiteral("yaw"), double(yawDegrees(lf.pose.yaw))},
{QStringLiteral("eyes"), double(lf.eyes.openness)},
{QStringLiteral("glare"), double(reading.glare)},
{QStringLiteral("device"), double(reading.device)},
{QStringLiteral("depth"), double(reading.depth)},
{QStringLiteral("blink"), double(reading.blink)},
{QStringLiteral("matched"), matched},
};
if (checked) {
info.insert(QStringLiteral("score"), double(score));
}
Q_EMIT event(info);
}
if (matched < MatchesNeeded || !lastCheckMatched) {
continue;
}
bool alive = true;
switch (mode) {
case LivenessMode::Off:
break;
case LivenessMode::Light:
alive = live.frameCount() >= LightFramesNeeded;
break;
case LivenessMode::Heavy:
alive = reading.confirmed;
if (!alive && elapsed - firstMatchAt > AskForLifeAfterMs) {
hint(QStringLiteral("blink"));
}
break;
}
if (alive) {
const Identity &id = m_faces.at(matchedIdentity);
finish(true, QStringLiteral("ok"),
{{QStringLiteral("name"), id.name},
{QStringLiteral("id"), id.id},
{QStringLiteral("score"), double(matchedScore)},
{QStringLiteral("liveness"), reading.confirmedBy},
{QStringLiteral("ms"), clock.elapsed()}});
return;
}
}
if (m_cancel) {
finish(false, QStringLiteral("cancelled"));
} else if (matched >= MatchesNeeded) {
finish(false, QStringLiteral("liveness"));
} else if (mismatched >= 3) {
finish(false, QStringLiteral("mismatch"), {{QStringLiteral("score"), double(bestSeen)}});
} else if (attentionMisses > 0) {
finish(false, QStringLiteral("attention"));
} else if (qualityMisses > 0) {
finish(false, QStringLiteral("quality"));
} else {
finish(false, QStringLiteral("no-face"));
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// One attempt at recognising somebody.
#pragma once
#include "job.h"
#include "settings.h"
#include "store.h"
class ScanJob : public Job
{
Q_OBJECT
public:
ScanJob(Vision *vision, uid_t uid, const Settings &settings, const QList<Identity> &faces, const QString &purpose, bool verbose);
void run() override;
QString kind() const override
{
return QStringLiteral("verify");
}
QString purpose() const
{
return m_purpose;
}
// Set on success: which face matched and what the camera saw, so the
// daemon can learn from it (see FaceStore::adapt).
int matchedIdentity = -1;
float matchedScore = 0;
Embedding matchedEmbedding;
private:
void finish(bool ok, const QString &reason, const QJsonObject &extra = {});
void hint(const QString &what);
Settings m_settings;
QList<Identity> m_faces;
QString m_purpose;
bool m_verbose;
QSet<QString> m_hinted;
};
+676
View File
@@ -0,0 +1,676 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The protocol: one JSON object per line, both ways. A client sends one
// request; the daemon answers with events and ends with one whose "event" is
// "result". "watch" never ends.
//
// hello version
// status [user] faces, lockout, camera, settings
// cameras every camera and which one is in use
// verify user purpose [verbose]
// scan for that user. Events: started, face,
// hint, frame (verbose only), result
// enroll [name] set up a face for the caller. Asks polkit
// first. Events: authorizing, authorized,
// started, frame, pose, hint, captured, result.
// While it runs the client may send "finish"
// (stop once enough is done) or "cancel".
// list [user] the caller's faces
// remove id | rename id name | enable id | disable id | clear
// change the caller's faces (polkit)
// watch every scan for the caller, as it happens,
// for the bubble
// unlocked the caller's session was unlocked some other
// way, which ends a lockout
// cancel stop this connection's scan or setup
//
// Who may do what:
// - anybody may ask about themselves and scan for themselves. The answer
// to a scan is yes or no, which tells a process nothing it could use.
// - root may do all of it for anybody. That is sudo and the polkit helper,
// through the PAM module.
// - changing faces needs polkit on top, because a face is a way in.
#include "server.h"
#include "agentlink.h"
#include "camera.h"
#include "client.h"
#include "enrolljob.h"
#include "polkit.h"
#include "scanjob.h"
#include "store.h"
#include "system.h"
#include "userstate.h"
#include "buildconfig.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QLocalSocket>
namespace
{
QJsonObject result(bool ok, const QString &reason = {})
{
QJsonObject o{{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), ok}};
if (!reason.isEmpty()) {
o.insert(QStringLiteral("reason"), reason);
}
return o;
}
bool isFailureThatCounts(const QString &reason)
{
// A face that did not match, a fake, or a match that never showed a sign
// of life. An empty chair, a broken camera, or somebody looking away do
// not count: none of them is anybody trying to get in.
return reason == u"mismatch" || reason == u"spoof" || reason == u"liveness";
}
} // namespace
Server::Server(const ServerOptions &options, QObject *parent)
: QObject(parent)
, m_options(options)
{
m_idle.setSingleShot(true);
connect(&m_idle, &QTimer::timeout, this, [] {
qInfo("idle, exiting");
QCoreApplication::quit();
});
}
Server::~Server()
{
if (m_job) {
m_job->cancel();
}
if (m_jobThread) {
m_jobThread->wait();
}
}
bool Server::start(QString *error)
{
QDir().mkpath(m_options.stateDir);
QFile::setPermissions(m_options.stateDir, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
bool ok;
if (m_options.systemdFd >= 0) {
ok = m_server.listen(qintptr(m_options.systemdFd));
} else {
QDir().mkpath(QFileInfo(m_options.socketPath).absolutePath());
QLocalServer::removeServer(m_options.socketPath);
m_server.setSocketOptions(QLocalServer::WorldAccessOption);
ok = m_server.listen(m_options.socketPath);
}
if (!ok) {
*error = m_server.errorString();
return false;
}
connect(&m_server, &QLocalServer::newConnection, this, &Server::onConnection);
// Loaded up front: whoever started the daemon is about to ask for a scan.
QString visionError;
if (!ensureVision(&visionError)) {
qWarning("%s", qPrintable(visionError));
}
updateIdle();
return true;
}
bool Server::ensureVision(QString *error)
{
if (!m_visionLoaded) {
m_visionLoaded = m_vision.load(m_options.modelDir, error);
}
return m_visionLoaded;
}
Settings Server::settings() const
{
return Settings::load(m_options.configPath);
}
void Server::onConnection()
{
while (QLocalSocket *socket = m_server.nextPendingConnection()) {
auto *client = new Client(socket, this);
if (!client->credentialsKnown()) {
client->deleteLater();
continue;
}
connect(client, &Client::request, this, &Server::onRequest);
connect(client, &Client::disconnected, this, &Server::onDisconnected);
m_clients.append(client);
}
updateIdle();
}
void Server::onDisconnected(Client *client)
{
// Whoever asked for a scan has given up on it (the PAM module timed out,
// the password was typed, the setup window was closed). The camera goes
// off now rather than when the scan would have ended.
if (m_job && m_jobOwner == client) {
m_job->cancel();
}
m_clients.removeAll(client);
client->deleteLater();
updateIdle();
}
void Server::reply(Client *client, QJsonObject message, bool close)
{
client->send(message);
if (close) {
client->close();
}
}
void Server::fail(Client *client, const QString &reason, const QJsonObject &extra)
{
QJsonObject o = extra;
o.insert(QStringLiteral("event"), QStringLiteral("result"));
o.insert(QStringLiteral("ok"), false);
o.insert(QStringLiteral("reason"), reason);
reply(client, o);
}
bool Server::targetUser(Client *client, const QJsonObject &request, uid_t *uid)
{
const QString name = request.value(u"user").toString();
if (name.isEmpty()) {
*uid = client->uid();
return true;
}
const std::optional<uid_t> target = System::uidOf(name);
if (!target) {
fail(client, QStringLiteral("unknown-user"));
return false;
}
if (client->uid() != 0 && client->uid() != *target) {
fail(client, QStringLiteral("denied"));
return false;
}
*uid = *target;
return true;
}
void Server::authorize(Client *client, std::function<void()> then)
{
if (client->uid() == 0 || !m_options.askPolkit) {
then();
return;
}
client->send({{QStringLiteral("event"), QStringLiteral("authorizing")}});
QPointer<Client> guard(client);
++m_manageChecks;
Polkit::checkAuthorization(client->pid(), client->uid(), Polkit::ManageAction, this, [this, guard, then](bool ok) {
--m_manageChecks;
if (!guard) {
return;
}
if (!ok) {
fail(guard, QStringLiteral("denied"));
return;
}
guard->send({{QStringLiteral("event"), QStringLiteral("authorized")}});
then();
});
}
void Server::onRequest(Client *client, const QJsonObject &request)
{
const QString cmd = request.value(u"cmd").toString();
// Messages for a scan or setup that is already running on this
// connection.
if (cmd == u"cancel" || cmd == u"finish") {
if (m_job && m_jobOwner == client) {
if (cmd == u"cancel") {
m_job->cancel();
} else if (auto *enroll = qobject_cast<EnrollJob *>(m_job)) {
enroll->finishEarly();
}
}
return;
}
if (!client->role.isEmpty()) {
// One request per connection.
return;
}
client->role = cmd;
if (cmd == u"hello") {
reply(client, {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), true}, {QStringLiteral("version"), QStringLiteral(PFU_VERSION)}});
} else if (cmd == u"status") {
handleStatus(client);
} else if (cmd == u"cameras") {
handleCameras(client);
} else if (cmd == u"verify") {
handleVerify(client, request);
} else if (cmd == u"enroll") {
handleEnroll(client, request);
} else if (cmd == u"list") {
handleList(client, request);
} else if (cmd == u"remove" || cmd == u"rename" || cmd == u"enable" || cmd == u"disable" || cmd == u"clear") {
handleChange(client, request);
} else if (cmd == u"watch") {
handleWatch(client);
} else if (cmd == u"unlocked") {
handleUnlocked(client);
} else {
fail(client, QStringLiteral("bad-request"));
}
}
// ---------------------------------------------------------------------------
// Questions
// ---------------------------------------------------------------------------
void Server::handleStatus(Client *client)
{
const uid_t uid = client->uid();
const Settings s = settings();
const FaceStore store(m_options.stateDir);
const QList<Identity> faces = store.load(uid);
const UserState state = UserState::load(m_options.stateDir, uid);
const qint64 now = QDateTime::currentSecsSinceEpoch();
int enabled = 0;
for (const Identity &id : faces) {
enabled += id.enabled;
}
QString path = s.camera;
if (path.isEmpty() || path == u"auto") {
path = Camera::autoPath();
}
QString cameraName;
bool present = false;
if (path.startsWith(u"file:") || path.startsWith(u"images:")) {
cameraName = path;
present = true;
} else {
for (const CameraInfo &c : Camera::list()) {
if (c.path == path) {
cameraName = c.name;
present = true;
}
}
}
QString modelError;
reply(client,
{{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), true},
{QStringLiteral("version"), QStringLiteral(PFU_VERSION)},
{QStringLiteral("user"), System::nameOf(uid)},
{QStringLiteral("faces"), enabled},
{QStringLiteral("identities"), int(faces.size())},
{QStringLiteral("lockout"), state.lockoutLeft(now)},
{QStringLiteral("lastUnlock"), state.lastUnlock},
{QStringLiteral("lastPurpose"), state.lastPurpose},
{QStringLiteral("camera"), s.camera},
{QStringLiteral("cameraPath"), path},
{QStringLiteral("cameraName"), cameraName},
{QStringLiteral("cameraPresent"), present},
{QStringLiteral("models"), ensureVision(&modelError)},
{QStringLiteral("liveness"), Settings::livenessName(s.liveness)},
{QStringLiteral("strictness"), Settings::strictnessName(s.strictness)},
{QStringLiteral("attention"), s.attention},
{QStringLiteral("scanSeconds"), s.scanSeconds},
{QStringLiteral("busy"), m_job != nullptr}});
}
void Server::handleCameras(Client *client)
{
QJsonArray list;
for (const CameraInfo &c : Camera::list()) {
list.append(QJsonObject{{QStringLiteral("path"), c.path}, {QStringLiteral("name"), c.name}, {QStringLiteral("infrared"), c.infrared}});
}
QJsonObject o = result(true);
o.insert(QStringLiteral("cameras"), list);
o.insert(QStringLiteral("selected"), settings().camera);
o.insert(QStringLiteral("auto"), Camera::autoPath());
reply(client, o);
}
void Server::handleList(Client *client, const QJsonObject &request)
{
uid_t uid;
if (!targetUser(client, request, &uid)) {
return;
}
const QList<Identity> faces = FaceStore(m_options.stateDir).load(uid);
QJsonArray list;
for (const Identity &id : faces) {
list.append(QJsonObject{
{QStringLiteral("id"), id.id},
{QStringLiteral("name"), id.name},
{QStringLiteral("created"), double(id.created)},
{QStringLiteral("enabled"), id.enabled},
{QStringLiteral("samples"), int(id.samples.size()) - id.adaptiveCount()},
{QStringLiteral("adaptive"), id.adaptiveCount()},
});
}
QJsonObject o = result(true);
o.insert(QStringLiteral("faces"), list);
reply(client, o);
}
void Server::handleWatch(Client *client)
{
reply(client, {{QStringLiteral("event"), QStringLiteral("watching")}}, false);
}
void Server::handleUnlocked(Client *client)
{
UserState state = UserState::load(m_options.stateDir, client->uid());
if (state.failures || state.lockedUntil) {
state.failures = 0;
state.lockedUntil = 0;
state.save(m_options.stateDir, client->uid());
}
reply(client, result(true));
}
// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------
void Server::handleVerify(Client *client, const QJsonObject &request)
{
uid_t uid;
if (!targetUser(client, request, &uid)) {
return;
}
if (m_job) {
fail(client, QStringLiteral("busy"));
return;
}
const Settings s = settings();
const qint64 now = QDateTime::currentSecsSinceEpoch();
const UserState state = UserState::load(m_options.stateDir, uid);
if (const qint64 left = state.lockoutLeft(now)) {
fail(client, QStringLiteral("lockout"), {{QStringLiteral("seconds"), left}});
return;
}
if (s.skipLidClosed && System::lidClosed()) {
fail(client, QStringLiteral("lid-closed"));
return;
}
QList<Identity> faces = FaceStore(m_options.stateDir).load(uid);
faces.erase(std::remove_if(faces.begin(), faces.end(), [](const Identity &id) {
return !id.enabled;
}),
faces.end());
if (faces.isEmpty()) {
fail(client, QStringLiteral("not-enrolled"));
return;
}
QString error;
if (!ensureVision(&error)) {
fail(client, QStringLiteral("models"), {{QStringLiteral("message"), error}});
return;
}
QString purpose = request.value(u"purpose").toString();
static const QStringList purposes = {QStringLiteral("unlock"), QStringLiteral("sudo"), QStringLiteral("polkit"), QStringLiteral("test")};
if (!purposes.contains(purpose)) {
purpose = QStringLiteral("other");
}
// The password dialog that is open right now may be the one asking
// whether faces may be changed. See m_manageChecks.
if (m_manageChecks > 0 && purpose != u"unlock" && purpose != u"test") {
fail(client, QStringLiteral("busy"));
return;
}
auto *job = new ScanJob(&m_vision, uid, s, faces, purpose, request.value(u"verbose").toBool());
// The lock screen's agent draws its own scans. Everybody else's it has
// to be told about.
if (purpose != u"unlock") {
m_agentLink = new AgentLink(uid, this);
}
broadcast(uid, {{QStringLiteral("event"), QStringLiteral("scan")}, {QStringLiteral("state"), QStringLiteral("start")}, {QStringLiteral("purpose"), purpose}});
startJob(job, client);
}
void Server::handleEnroll(Client *client, const QJsonObject &request)
{
uid_t uid;
if (!targetUser(client, request, &uid)) {
return;
}
if (m_job) {
fail(client, QStringLiteral("busy"));
return;
}
QString name = request.value(u"name").toString().trimmed().left(64);
authorize(client, [this, client, uid, name]() mutable {
if (m_job) {
fail(client, QStringLiteral("busy"));
return;
}
QString error;
if (!ensureVision(&error)) {
fail(client, QStringLiteral("models"), {{QStringLiteral("message"), error}});
return;
}
if (name.isEmpty()) {
name = System::nameOf(uid);
}
startJob(new EnrollJob(&m_vision, uid, settings(), name), client);
});
}
void Server::startJob(Job *job, Client *owner)
{
m_job = job;
m_jobOwner = owner;
connect(job, &Job::event, this, &Server::onJobEvent, Qt::QueuedConnection);
m_jobThread = QThread::create([job] {
job->run();
});
connect(m_jobThread, &QThread::finished, this, &Server::onJobDone, Qt::QueuedConnection);
m_jobThread->start();
updateIdle();
}
void Server::onJobEvent(const QJsonObject &event)
{
if (!m_job) {
return;
}
if (m_jobOwner) {
m_jobOwner->send(event);
}
// The bubble hears about scans, not about what the setup window sees.
if (auto *scan = qobject_cast<ScanJob *>(m_job)) {
const QString what = event.value(u"event").toString();
if (what == u"face" || what == u"hint") {
QJsonObject e{{QStringLiteral("event"), QStringLiteral("scan")},
{QStringLiteral("state"), what},
{QStringLiteral("purpose"), scan->purpose()}};
if (what == u"hint") {
e.insert(QStringLiteral("hint"), event.value(u"hint"));
}
broadcast(scan->uid(), e);
}
}
}
void Server::onJobDone()
{
Job *job = m_job;
m_job = nullptr;
m_jobThread->deleteLater();
m_jobThread = nullptr;
QPointer<Client> owner = m_jobOwner;
m_jobOwner = nullptr;
QJsonObject res = job->result;
const qint64 now = QDateTime::currentSecsSinceEpoch();
if (auto *scan = qobject_cast<ScanJob *>(job)) {
const Settings s = settings();
UserState state = UserState::load(m_options.stateDir, scan->uid());
const QString reason = res.value(u"reason").toString();
if (res.value(u"ok").toBool()) {
state.failures = 0;
state.lockedUntil = 0;
state.lastUnlock = now;
state.lastPurpose = scan->purpose();
// Learn from a confident match, the way Face ID keeps up with a
// beard growing in. Only well clear of the threshold, so the
// samples cannot creep towards somebody else one borderline
// unlock at a time.
if (s.adapt && scan->matchedScore >= s.threshold() + 0.08 && !scan->matchedEmbedding.empty()) {
const FaceStore store(m_options.stateDir);
QList<Identity> faces = store.load(scan->uid());
const QString id = res.value(u"id").toString();
for (Identity &identity : faces) {
if (identity.id == id && FaceStore::adapt(identity, scan->matchedEmbedding, now)) {
QString error;
if (!store.save(scan->uid(), faces, &error)) {
qWarning("could not save what was learned: %s", qPrintable(error));
}
break;
}
}
}
} else if (isFailureThatCounts(reason)) {
if (++state.failures >= s.maxFailures) {
state.failures = 0;
state.lockedUntil = now + qint64(s.lockoutMinutes) * 60;
res.insert(QStringLiteral("lockout"), state.lockoutLeft(now));
}
}
state.save(m_options.stateDir, scan->uid());
QJsonObject e{{QStringLiteral("event"), QStringLiteral("scan")},
{QStringLiteral("state"), res.value(u"ok").toBool() ? QStringLiteral("success") : QStringLiteral("failure")},
{QStringLiteral("reason"), reason},
{QStringLiteral("purpose"), scan->purpose()}};
if (res.contains(u"lockout")) {
e.insert(QStringLiteral("lockout"), res.value(u"lockout"));
}
broadcast(scan->uid(), e);
qInfo("scan for %s (%s): %s%s", qPrintable(System::nameOf(scan->uid())), qPrintable(scan->purpose()),
res.value(u"ok").toBool() ? "recognised" : "not recognised, ", res.value(u"ok").toBool() ? "" : qPrintable(reason));
} else if (auto *enroll = qobject_cast<EnrollJob *>(job)) {
if (res.value(u"ok").toBool()) {
const FaceStore store(m_options.stateDir);
QList<Identity> faces = store.load(enroll->uid());
faces.append(enroll->identity);
QString error;
if (!store.save(enroll->uid(), faces, &error)) {
res = result(false, QStringLiteral("store"));
res.insert(QStringLiteral("message"), error);
} else {
qInfo("new face \"%s\" for %s", qPrintable(enroll->identity.name), qPrintable(System::nameOf(enroll->uid())));
}
}
}
if (owner) {
reply(owner, res);
}
job->deleteLater();
updateIdle();
}
void Server::broadcast(uid_t uid, const QJsonObject &event)
{
if (m_agentLink) {
m_agentLink->send(event);
const QString state = event.value(u"state").toString();
if (state == u"success" || state == u"failure") {
m_agentLink->finish();
m_agentLink = nullptr;
}
}
for (Client *c : std::as_const(m_clients)) {
if (c->role == u"watch" && c->uid() == uid) {
c->send(event);
}
}
}
// ---------------------------------------------------------------------------
// Changing faces
// ---------------------------------------------------------------------------
void Server::handleChange(Client *client, const QJsonObject &request)
{
uid_t uid;
if (!targetUser(client, request, &uid)) {
return;
}
const QString cmd = request.value(u"cmd").toString();
const QString id = request.value(u"id").toString();
const QString name = request.value(u"name").toString().trimmed().left(64);
authorize(client, [this, client, uid, cmd, id, name] {
const FaceStore store(m_options.stateDir);
QList<Identity> faces = store.load(uid);
bool found = cmd == u"clear";
if (cmd == u"clear") {
faces.clear();
}
for (qsizetype i = 0; i < faces.size(); ++i) {
if (faces[i].id != id) {
continue;
}
found = true;
if (cmd == u"remove") {
faces.removeAt(i);
} else if (cmd == u"rename" && !name.isEmpty()) {
faces[i].name = name;
} else if (cmd == u"enable") {
faces[i].enabled = true;
} else if (cmd == u"disable") {
faces[i].enabled = false;
}
break;
}
if (!found) {
fail(client, QStringLiteral("unknown-face"));
return;
}
QString error;
if (!store.save(uid, faces, &error)) {
fail(client, QStringLiteral("store"), {{QStringLiteral("message"), error}});
return;
}
reply(client, result(true));
});
}
// ---------------------------------------------------------------------------
void Server::updateIdle()
{
if (m_options.idleSeconds > 0 && m_clients.isEmpty() && !m_job) {
m_idle.start(m_options.idleSeconds * 1000);
} else {
m_idle.stop();
}
}
+96
View File
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The socket, and who may ask for what.
#pragma once
#include "settings.h"
#include "vision.h"
#include <QJsonObject>
#include <QList>
#include <QLocalServer>
#include <QObject>
#include <QPointer>
#include <QThread>
#include <QTimer>
#include <sys/types.h>
class AgentLink;
class Client;
class Job;
struct ServerOptions {
QString socketPath;
int systemdFd = -1;
QString stateDir;
QString configPath;
QString modelDir;
// Exit after this long with nothing to do. 0 stays up.
int idleSeconds = 0;
// Running as somebody other than root is only ever development, and only
// touches that person's own test data. polkit is not asked then.
bool askPolkit = true;
};
class Server : public QObject
{
Q_OBJECT
public:
explicit Server(const ServerOptions &options, QObject *parent = nullptr);
~Server() override;
bool start(QString *error);
private:
void onConnection();
void onRequest(Client *client, const QJsonObject &request);
void onDisconnected(Client *client);
void handleStatus(Client *client);
void handleCameras(Client *client);
void handleVerify(Client *client, const QJsonObject &request);
void handleEnroll(Client *client, const QJsonObject &request);
void handleList(Client *client, const QJsonObject &request);
void handleChange(Client *client, const QJsonObject &request);
void handleWatch(Client *client);
void handleUnlocked(Client *client);
// The user a request is about: the caller, or for root whoever it names.
// Returns false (and answers) when the caller may not act for them.
bool targetUser(Client *client, const QJsonObject &request, uid_t *uid);
void authorize(Client *client, std::function<void()> then);
void reply(Client *client, QJsonObject message, bool close = true);
void fail(Client *client, const QString &reason, const QJsonObject &extra = {});
bool ensureVision(QString *error);
Settings settings() const;
void startJob(Job *job, Client *owner);
void onJobEvent(const QJsonObject &event);
void onJobDone();
void broadcast(uid_t uid, const QJsonObject &event);
void updateIdle();
ServerOptions m_options;
QLocalServer m_server;
QList<Client *> m_clients;
Vision m_vision;
bool m_visionLoaded = false;
Job *m_job = nullptr;
QThread *m_jobThread = nullptr;
QPointer<Client> m_jobOwner;
// The session to tell about the running scan, when it is not the lock
// screen's own (see agentlink.h).
QPointer<AgentLink> m_agentLink;
QTimer m_idle;
// Asking polkit whether faces may be changed. While that question is
// open, a scan for an admin prompt is refused: the answer to "may a face
// be added" has to be the password, not a face.
int m_manageChecks = 0;
};
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "system.h"
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDir>
#include <QFile>
#include <pwd.h>
#include <unistd.h>
#include <vector>
namespace System
{
std::optional<uid_t> uidOf(const QString &user)
{
if (user.isEmpty()) {
return std::nullopt;
}
std::vector<char> buf(16384);
passwd pw{};
passwd *result = nullptr;
if (getpwnam_r(user.toLocal8Bit().constData(), &pw, buf.data(), buf.size(), &result) != 0 || !result) {
return std::nullopt;
}
return result->pw_uid;
}
QString nameOf(uid_t uid)
{
std::vector<char> buf(16384);
passwd pw{};
passwd *result = nullptr;
if (getpwuid_r(uid, &pw, buf.data(), buf.size(), &result) != 0 || !result) {
return QString::number(uid);
}
return QString::fromLocal8Bit(result->pw_name);
}
bool lidClosed()
{
QDBusInterface logind(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QDBusConnection::systemBus());
if (logind.isValid()) {
const QVariant v = logind.property("LidClosed");
if (v.isValid()) {
return v.toBool();
}
}
const QDir lids(QStringLiteral("/proc/acpi/button/lid"));
for (const QString &lid : lids.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
QFile state(lids.filePath(lid + QStringLiteral("/state")));
if (state.open(QIODevice::ReadOnly) && state.readAll().contains("closed")) {
return true;
}
}
return false;
}
quint64 processStartTime(pid_t pid)
{
QFile stat(QStringLiteral("/proc/%1/stat").arg(pid));
if (!stat.open(QIODevice::ReadOnly)) {
return 0;
}
const QByteArray line = stat.readAll();
// The second field is the command name in brackets and can contain
// spaces and brackets of its own. Everything after the last ')' is
// plain numbers; the start time is the 20th of them.
const qsizetype close = line.lastIndexOf(')');
if (close < 0) {
return 0;
}
const QList<QByteArray> fields = line.mid(close + 2).split(' ');
return fields.size() > 19 ? fields.at(19).toULongLong() : 0;
}
} // namespace System
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Small questions about the machine and its users.
#pragma once
#include <QString>
#include <optional>
#include <sys/types.h>
namespace System
{
std::optional<uid_t> uidOf(const QString &user);
QString nameOf(uid_t uid);
// Whether a laptop lid is shut. Asks logind, and falls back to ACPI.
bool lidClosed();
// When a process started, in clock ticks since boot, as polkit wants it to
// tell a process from a later one that got the same pid.
quint64 processStartTime(pid_t pid);
} // namespace System
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "userstate.h"
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
namespace
{
QString fileFor(const QString &stateDir, uid_t uid)
{
return QDir(stateDir).filePath(QStringLiteral("users/%1.state").arg(uid));
}
} // namespace
UserState UserState::load(const QString &stateDir, uid_t uid)
{
UserState s;
QFile file(fileFor(stateDir, uid));
if (!file.open(QIODevice::ReadOnly)) {
return s;
}
const QJsonObject o = QJsonDocument::fromJson(file.readAll()).object();
s.failures = o.value(u"failures").toInt();
s.lockedUntil = qint64(o.value(u"lockedUntil").toDouble());
s.lastUnlock = qint64(o.value(u"lastUnlock").toDouble());
s.lastPurpose = o.value(u"lastPurpose").toString();
return s;
}
bool UserState::save(const QString &stateDir, uid_t uid) const
{
QDir().mkpath(QDir(stateDir).filePath(QStringLiteral("users")));
QSaveFile file(fileFor(stateDir, uid));
if (!file.open(QIODevice::WriteOnly)) {
return false;
}
file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
const QJsonObject o{
{QStringLiteral("failures"), failures},
{QStringLiteral("lockedUntil"), double(lockedUntil)},
{QStringLiteral("lastUnlock"), double(lastUnlock)},
{QStringLiteral("lastPurpose"), lastPurpose},
};
file.write(QJsonDocument(o).toJson(QJsonDocument::Compact));
return file.commit();
}
qint64 UserState::lockoutLeft(qint64 now) const
{
return lockedUntil > now ? lockedUntil - now : 0;
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// What the daemon remembers about each user between scans: failures in a row,
// the lockout they lead to, and the last successful unlock.
//
// Kept on disk rather than in memory. The daemon exits when it has been idle
// for a minute, and a lockout that ended with the process would be a lockout
// that waiting a minute gets around.
#pragma once
#include <QString>
#include <sys/types.h>
struct UserState {
int failures = 0;
qint64 lockedUntil = 0;
qint64 lastUnlock = 0;
QString lastPurpose;
static UserState load(const QString &stateDir, uid_t uid);
bool save(const QString &stateDir, uid_t uid) const;
// Seconds left, 0 when not locked out.
qint64 lockoutLeft(qint64 now) const;
};
+182
View File
@@ -0,0 +1,182 @@
# shellcheck shell=bash
#
# Paths, translations and output helpers.
#
# The shell side never touches the camera or the face data. Those belong to
# the daemon, and everything here that needs them asks it through
# plasma-face-unlock-ctl. What this side does write is the user's own settings
# file, and (through sudo, and only when asked) the system settings and the
# PAM files of sudo and polkit.
PFU_VERSION="@VERSION@"
PFU_NAME="plasma-face-unlock"
PFU_PRETTY="Plasma Face Unlock"
PFU_LIBDIR="${PFU_LIBDIR:-@LIBDIR@}"
PFU_LIBEXECDIR="${PFU_LIBEXECDIR:-@LIBEXECDIR@}"
PFU_LOCALEDIR="${PFU_LOCALEDIR:-@LOCALEDIR@}"
PFU_PAMDIR="${PFU_PAMDIR:-@PAMDIR@}"
PFU_CTL="${PFU_CTL:-$PFU_LIBEXECDIR/plasma-face-unlock-ctl}"
PFU_AGENT="${PFU_AGENT:-$PFU_LIBEXECDIR/plasma-face-unlock-agent}"
PFU_PAM_MODULE="${PFU_PAM_MODULE:-$PFU_PAMDIR/pam_plasma_face_unlock.so}"
PFU_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}"
PFU_CONFDIR="${PFU_CONFDIR:-${PFU_XDG_CONFIG}/${PFU_NAME}}"
PFU_CONFIG="${PFU_CONFIG:-${PFU_CONFDIR}/config}"
# The system settings. Only root writes them; see system.sh.
PFU_SYSCONFIG="${PFU_SYSCONFIG:-/etc/${PFU_NAME}/config}"
PFU_UNIT_SOCKET="plasma-face-unlockd.socket"
PFU_UNIT_AGENT="plasma-face-unlock-agent.service"
# ---------------------------------------------------------------------------
# Translations
# ---------------------------------------------------------------------------
export TEXTDOMAIN="plasma-face-unlock"
export TEXTDOMAINDIR="${PFU_LOCALEDIR}"
pfu_ui_locale() {
local l="${PFU_UI_LOCALE:-}"
if [[ -z $l ]]; then
l="${LC_ALL:-}"
[[ -z $l ]] && l="${LC_MESSAGES:-}"
[[ -z $l ]] && l="${LANG:-}"
fi
# systemd writes /etc/locale.conf and most distributions use it; Debian and
# Ubuntu keep the same LANG= line in /etc/default/locale instead.
if [[ -z $l ]]; then
local f
for f in /etc/locale.conf /etc/default/locale; do
[[ -r $f ]] || continue
l="$(sed -n 's/^LANG=//p' "$f" | tr -d '"' | head -n1)"
[[ -n $l ]] && break
done
fi
printf '%s\n' "${l:-C}"
}
# Every gettext lookup is a fork and the settings screen redraws a screenful of
# labels per keypress, so results are memoized.
declare -A PFU_MSG_CACHE=()
PFU_MSG_RESULT=''
# pfu_msg_into <locale> <msgid>
# Plain lookup with the result in PFU_MSG_RESULT and no printf formatting, for
# callers that would otherwise pay a fork per label per frame.
pfu_msg_into() {
local locale="$1" msgid="$2" cachekey
cachekey="${locale}"$'\x1f'"${msgid}"
if [[ -n ${PFU_MSG_CACHE[$cachekey]+set} ]]; then
PFU_MSG_RESULT="${PFU_MSG_CACHE[$cachekey]}"
return 0
fi
PFU_MSG_RESULT="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
[[ -n $PFU_MSG_RESULT ]] || PFU_MSG_RESULT="$msgid"
PFU_MSG_CACHE[$cachekey]="$PFU_MSG_RESULT"
return 0
}
# pfu_msg_in <locale> <msgid> [printf args...]
pfu_msg_in() {
local locale="$1" msgid="$2"
shift 2
pfu_msg_into "$locale" "$msgid"
# With no arguments the message is plain text, not a format string. Feeding
# it to printf anyway would turn a literal percent sign in a translation
# into an invalid conversion.
if (( $# == 0 )); then
printf '%s' "$PFU_MSG_RESULT"
return
fi
# shellcheck disable=SC2059 # the format string is the translated message
printf -- "$PFU_MSG_RESULT" "$@"
}
PFU_LOCALE_CACHED=''
# pfu_msg <msgid> [printf args...]
pfu_msg() {
[[ -n $PFU_LOCALE_CACHED ]] || PFU_LOCALE_CACHED="$(pfu_ui_locale)"
pfu_msg_in "$PFU_LOCALE_CACHED" "$@"
}
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
# Decided once, while stdout is still whatever the process was started with:
# testing -t 1 at the point of use is wrong for anything called through $(...),
# which sees a pipe and would conclude nobody is watching.
PFU_INTERACTIVE=''
[[ -t 1 ]] && PFU_INTERACTIVE=1
if [[ -n $PFU_INTERACTIVE && -z ${NO_COLOR:-} ]]; then
PFU_C_RESET=$'\033[0m'
PFU_C_BOLD=$'\033[1m'
PFU_C_DIM=$'\033[2m'
PFU_C_BLUE=$'\033[38;2;52;153;255m'
PFU_C_GREEN=$'\033[32m'
PFU_C_YELLOW=$'\033[33m'
PFU_C_RED=$'\033[31m'
else
PFU_C_RESET='' PFU_C_BOLD='' PFU_C_DIM='' PFU_C_BLUE=''
PFU_C_GREEN='' PFU_C_YELLOW='' PFU_C_RED=''
fi
# Set by pfu_bad and pfu_note. The menu redraws straight after an action, which
# would wipe the screen; this marks that something was printed the user still
# has to read.
PFU_UI_NEEDS_ACK=''
pfu_say() { printf '%s\n' "$*"; }
pfu_head() { printf '\n%s%s%s\n\n' "$PFU_C_BOLD$PFU_C_BLUE" "$*" "$PFU_C_RESET"; }
pfu_ok() { printf '%s✔%s %s\n' "$PFU_C_GREEN" "$PFU_C_RESET" "$*"; }
pfu_bad() { PFU_UI_NEEDS_ACK=1; printf '%s✘%s %s\n' "$PFU_C_RED" "$PFU_C_RESET" "$*" >&2; }
pfu_note() { PFU_UI_NEEDS_ACK=1; printf '%s•%s %s\n' "$PFU_C_DIM" "$PFU_C_RESET" "$*"; }
pfu_have() { command -v "$1" > /dev/null 2>&1; }
# Human-readable "x minutes ago" for a unix timestamp. 0 or empty yields the
# translated "never".
#
# Written with [[ ]] and a variable for each number on purpose: xgettext reads
# the < of an (( )) as a redirection and loses every string after it.
pfu_time_ago() {
local ts="$1" now delta n
if [[ ! $ts =~ ^[0-9]+$ || $ts -eq 0 ]]; then
pfu_msg "never"
printf '\n'
return
fi
now="$(date +%s)"
delta=$(( now - ts ))
[[ $delta -lt 0 ]] && delta=0
if [[ $delta -lt 60 ]]; then
pfu_msg "just now"
elif [[ $delta -lt 3600 ]]; then
n=$(( delta / 60 ))
pfu_msg "%d minutes ago" "$n"
elif [[ $delta -lt 86400 ]]; then
n=$(( delta / 3600 ))
pfu_msg "%d hours ago" "$n"
else
n=$(( delta / 86400 ))
pfu_msg "%d days ago" "$n"
fi
printf '\n'
}
+132
View File
@@ -0,0 +1,132 @@
# shellcheck shell=bash
#
# Reading and writing the settings files.
#
# Two of them, in the same format: ~/.config/plasma-face-unlock/config for
# what this user wants from the lock screen and the bubble, and
# /etc/plasma-face-unlock/config for what the daemon does (which camera, how
# strict), which only root writes. Neither is meant to be edited by hand:
# every option is in the menu.
#
# Both are parsed rather than sourced. One "Key=Value" per line, '#'
# comments. The daemon and the agent read them with the same rules (see
# src/core/keyvalue.cpp).
declare -A PFU_KV_CACHE=()
# _pfu_kv_lookup <file> <Key> [default]
# Result in PFU_KV_VALUE. Assigning rather than printing matters on the
# settings screen, which reads every key on every frame: a command
# substitution there is a fork, and forks are the whole cost of a redraw.
PFU_KV_VALUE=''
_pfu_kv_lookup() {
local file="$1" key="$2" default="${3:-}" val='' line content
PFU_KV_VALUE="$default"
[[ -r $file ]] || return 0
if [[ -n ${PFU_KV_CACHE[$file]+set} ]]; then
content="${PFU_KV_CACHE[$file]}"
else
content="$(< "$file")"
PFU_KV_CACHE[$file]="$content"
fi
while IFS= read -r line; do
[[ $line == *"$key"* ]] || continue
[[ $line =~ ^[[:space:]]*"$key"[[:space:]]*=(.*)$ ]] || continue
val="${BASH_REMATCH[1]}"
done <<< "$content"
val="${val%%#*}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
val="${val%\"}"
val="${val#\"}"
[[ -n $val ]] && PFU_KV_VALUE="$val"
return 0
}
pfu_is_true() {
case "${1,,}" in
yes|y|true|1|on|enabled) return 0 ;;
*) return 1 ;;
esac
}
# pfu_kv_set <file> <Key> <Value> <header line>
pfu_kv_set() {
local file="$1" key="$2" value="$3" header="$4" tmp
if [[ ! -e $file ]]; then
mkdir -p "$(dirname "$file")" || return 1
{
printf '# %s\n' "$header"
printf '#\n'
printf '# Written by `%s`. Nothing here needs editing by hand:\n' "$PFU_NAME"
printf '# every option is in the menu.\n'
} > "$file" || return 1
fi
[[ -w $file ]] || return 1
tmp="$(mktemp "${file}.XXXXXX")" || return 1
chmod --reference="$file" "$tmp" 2>/dev/null || chmod 0644 "$tmp"
if grep -qE "^[[:space:]]*#?[[:space:]]*${key}[[:space:]]*=" "$file"; then
awk -v key="$key" -v value="$value" '
!done && $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" {
print key "=" value; done = 1; next
}
# drop any further occurrences so the file cannot grow duplicates
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" { next }
{ print }
' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
else
cat "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
# Replaced, not rewritten in place: the agent watches the directory and
# picks up the new file the moment it lands.
mv -f "$tmp" "$file"
unset 'PFU_KV_CACHE[$file]'
}
# ---------------------------------------------------------------------------
# This user's settings
# ---------------------------------------------------------------------------
pfu_config_get() {
_pfu_kv_lookup "$PFU_CONFIG" "$@"
printf '%s\n' "$PFU_KV_VALUE"
}
pfu_config_set() {
pfu_kv_set "$PFU_CONFIG" "$1" "$2" "$PFU_PRETTY"
}
# ---------------------------------------------------------------------------
# The system settings (read by anybody, written by root)
# ---------------------------------------------------------------------------
pfu_sys_get() {
_pfu_kv_lookup "$PFU_SYSCONFIG" "$@"
printf '%s\n' "$PFU_KV_VALUE"
}
# pfu_config_load
# Everything the menu shows, resolved once per screen.
pfu_config_load() {
PFU_KV_CACHE=()
_pfu_kv_lookup "$PFU_CONFIG" Enabled no; CFG_ENABLED=no; pfu_is_true "$PFU_KV_VALUE" && CFG_ENABLED=yes
_pfu_kv_lookup "$PFU_CONFIG" LockScreen yes; CFG_LOCK=no; pfu_is_true "$PFU_KV_VALUE" && CFG_LOCK=yes
_pfu_kv_lookup "$PFU_CONFIG" Sudo no; CFG_SUDO=no; pfu_is_true "$PFU_KV_VALUE" && CFG_SUDO=yes
_pfu_kv_lookup "$PFU_CONFIG" Polkit no; CFG_POLKIT=no; pfu_is_true "$PFU_KV_VALUE" && CFG_POLKIT=yes
_pfu_kv_lookup "$PFU_SYSCONFIG" Liveness heavy; CFG_LIVENESS="$PFU_KV_VALUE"
_pfu_kv_lookup "$PFU_SYSCONFIG" Camera auto; CFG_CAMERA="$PFU_KV_VALUE"
return 0
}
+185
View File
@@ -0,0 +1,185 @@
# shellcheck shell=bash
#
# Asking the daemon.
#
# Through plasma-face-unlock-ctl, which prints every message as a line of tab
# separated key=value pairs (see src/ctl/main.cpp). What comes back is parsed
# into plain variables and arrays here, so the rest of the shell code never
# sees the wire format.
# _pfu_fields <line>
# Splits one line of ctl output into the associative array PFU_F.
declare -A PFU_F=()
_pfu_fields() {
local line="$1" field
local -a parts
PFU_F=()
IFS=$'\t' read -r -a parts <<< "$line"
for field in "${parts[@]}"; do
PFU_F["${field%%=*}"]="${field#*=}"
done
}
pfu_ctl() {
"$PFU_CTL" "$@"
}
# pfu_status_load
# PFU_ST_REACHABLE is yes when the daemon answered at all. Everything else
# is only filled in then.
pfu_status_load() {
local out
PFU_ST_REACHABLE=no
PFU_ST_FACES=0
PFU_ST_LOCKOUT=0
PFU_ST_LAST=0
PFU_ST_PURPOSE=''
PFU_ST_CAMERA=''
PFU_ST_CAMERA_NAME=''
PFU_ST_CAMERA_PRESENT=no
PFU_ST_MODELS=no
out="$(pfu_ctl status 2>/dev/null | tail -n1)"
_pfu_fields "$out"
[[ ${PFU_F[ok]:-} == true ]] || return 1
PFU_ST_REACHABLE=yes
PFU_ST_FACES="${PFU_F[faces]:-0}"
PFU_ST_LOCKOUT="${PFU_F[lockout]:-0}"
PFU_ST_LAST="${PFU_F[lastUnlock]:-0}"
PFU_ST_PURPOSE="${PFU_F[lastPurpose]:-}"
PFU_ST_CAMERA="${PFU_F[cameraPath]:-}"
PFU_ST_CAMERA_NAME="${PFU_F[cameraName]:-}"
PFU_ST_CAMERA_PRESENT="${PFU_F[cameraPresent]:-false}"
PFU_ST_MODELS="${PFU_F[models]:-false}"
return 0
}
# pfu_faces_load
# The caller's faces, into parallel arrays.
pfu_faces_load() {
local line
PFU_FACE_IDS=() PFU_FACE_NAMES=() PFU_FACE_ON=() PFU_FACE_SAMPLES=() PFU_FACE_LEARNED=() PFU_FACE_CREATED=()
while IFS= read -r line; do
_pfu_fields "$line"
[[ ${PFU_F[event]:-} == item ]] || continue
PFU_FACE_IDS+=("${PFU_F[id]}")
PFU_FACE_NAMES+=("${PFU_F[name]}")
PFU_FACE_ON+=("${PFU_F[enabled]}")
PFU_FACE_SAMPLES+=("${PFU_F[samples]:-0}")
PFU_FACE_LEARNED+=("${PFU_F[adaptive]:-0}")
PFU_FACE_CREATED+=("${PFU_F[created]:-0}")
done < <(pfu_ctl list 2>/dev/null)
}
# pfu_cameras_load
pfu_cameras_load() {
local line
PFU_CAM_PATHS=() PFU_CAM_NAMES=() PFU_CAM_IR=()
PFU_CAM_AUTO=''
while IFS= read -r line; do
_pfu_fields "$line"
case "${PFU_F[event]:-}" in
item)
PFU_CAM_PATHS+=("${PFU_F[path]}")
PFU_CAM_NAMES+=("${PFU_F[name]}")
PFU_CAM_IR+=("${PFU_F[infrared]}")
;;
result)
PFU_CAM_AUTO="${PFU_F[auto]:-}"
;;
esac
done < <(pfu_ctl cameras 2>/dev/null)
}
# pfu_reason_text <reason>
# What a daemon answer means, for people.
pfu_reason_text() {
case "$1" in
mismatch) pfu_msg "The face did not match." ;;
spoof) pfu_msg "That looked like a photo or a screen." ;;
liveness) pfu_msg "The face matched, but did not blink or move." ;;
attention) pfu_msg "The face was not looking at the screen." ;;
quality) pfu_msg "The picture was too dark, too blurry or too far away." ;;
no-face) pfu_msg "No face in view." ;;
lockout) pfu_msg "Face unlock is paused after too many tries. Unlock once with your password." ;;
not-enrolled) pfu_msg "No face is set up yet." ;;
camera) pfu_msg "The camera could not be used." ;;
models) pfu_msg "The recognition models are missing. Reinstall the package." ;;
lid-closed) pfu_msg "The lid is closed." ;;
busy) pfu_msg "The camera is busy with another scan." ;;
unreachable) pfu_msg "The face unlock service is not running." ;;
denied) pfu_msg "Not allowed." ;;
*) pfu_msg "Something went wrong (%s)." "$1" ;;
esac
printf '\n'
}
# pfu_test
# One scan, with everything the checks see on one line that keeps updating.
# The bubble shows it too, if the agent is running.
pfu_test() {
local line started=0 shown='' score='-' matched=0
pfu_say " $(pfu_msg "Look at the camera. Blink once, or turn your head a little.")"
printf '\n'
while IFS= read -r line; do
_pfu_fields "$line"
case "${PFU_F[event]:-}" in
started)
started=1
;;
face)
printf '\r\033[K %s\n' "$(pfu_msg "Face found.")"
;;
hint)
case "${PFU_F[hint]}" in
blink) printf '\r\033[K %s\n' "$(pfu_msg "Recognised. Now blink once, or turn your head a little.")" ;;
look) printf '\r\033[K %s\n' "$(pfu_msg "Look at the screen.")" ;;
closer) printf '\r\033[K %s\n' "$(pfu_msg "Move closer to the camera.")" ;;
light) printf '\r\033[K %s\n' "$(pfu_msg "It is too dark to see your face.")" ;;
esac
;;
frame)
[[ -n ${PFU_F[score]:-} ]] && score="${PFU_F[score]}"
matched="${PFU_F[matched]:-0}"
# match, turn of the head, eyes, and how close each photo check is
# to firing, as numbers for anybody tuning it.
printf -v shown ' %s %-6s %s %5s° %s %-5s %s %-4s %s %-4s %s %-4s %s %-4s' \
"$(pfu_msg "match")" "$score" "$(pfu_msg "turn")" "${PFU_F[yaw]:-0}" \
"$(pfu_msg "eyes")" "${PFU_F[eyes]:-0}" \
"$(pfu_msg "depth")" "${PFU_F[depth]:-0}" "$(pfu_msg "blink")" "${PFU_F[blink]:-0}" \
"$(pfu_msg "glare")" "${PFU_F[glare]:-0}" "$(pfu_msg "edge")" "${PFU_F[device]:-0}"
[[ -n $PFU_INTERACTIVE ]] && printf '\r\033[K%s%s%s' "$PFU_C_DIM" "$shown" "$PFU_C_RESET"
;;
result)
printf '\r\033[K'
if [[ ${PFU_F[ok]} == true ]]; then
local how=''
case "${PFU_F[liveness]:-}" in
blink) how="$(pfu_msg "blink")" ;;
depth) how="$(pfu_msg "head turn")" ;;
esac
pfu_ok "$(pfu_msg "Recognised as %s (match %s) in %s ms." "${PFU_F[name]}" "${PFU_F[score]}" "${PFU_F[ms]}")"
[[ -n $how ]] && pfu_say " $(pfu_msg "Sign of life: %s" "$how")"
else
pfu_bad "$(pfu_reason_text "${PFU_F[reason]}")"
[[ -n ${PFU_F[message]:-} ]] && pfu_say " ${PFU_F[message]}"
if [[ -n ${PFU_F[lockout]:-} ]]; then
pfu_note "$(pfu_msg "That was too many tries. Face unlock is paused until you unlock with your password.")"
fi
fi
(( started )) || true
return 0
;;
esac
done < <(pfu_ctl test 2>/dev/null)
printf '\n'
pfu_bad "$(pfu_reason_text unreachable)"
return 1
}
+584
View File
@@ -0,0 +1,584 @@
# shellcheck shell=bash
#
# The interactive front end.
#
# This is the whole configuration interface. There are two files behind it
# and the face data behind the daemon, but no part of the program ever asks
# anybody to open one: one switch on the front screen, the faces, a settings
# list, and a way to try it.
# Terminal mode.
#
# bash flips the terminal into non-canonical mode for each `read -sn1` and back
# out again in between. That gap matters: in canonical mode DEL is the ERASE
# character, so the line discipline eats it instead of delivering it, and a
# backspace typed while the interface was between reads simply vanishes.
# Holding non-canonical mode for the whole interface removes the gap.
PFU_TERM_SAVED=''
pfu_ui_term_raw() {
pfu_have stty || return 0
[[ -t 0 ]] || return 0
[[ -n $PFU_TERM_SAVED ]] && return 0
PFU_TERM_SAVED="$(stty -g 2>/dev/null)" || { PFU_TERM_SAVED=''; return 0; }
stty -icanon -echo min 1 time 0 2>/dev/null || true
}
pfu_ui_term_restore() {
[[ -n $PFU_TERM_SAVED ]] || return 0
stty "$PFU_TERM_SAVED" 2>/dev/null || true
PFU_TERM_SAVED=''
}
# Runs an action with the terminal handed back to normal line mode, so anything
# it prints or prompts for (sudo's password prompt, above all) behaves the way
# a program expects.
pfu_ui_cooked() {
pfu_ui_term_restore
"$@"
local rc=$?
pfu_ui_term_raw
return $rc
}
# pfu_read_key
# One keypress, resolved to a symbolic name. Arrow keys arrive as ESC [ A, so
# the tail of the sequence is consumed here rather than being mistaken for
# three separate presses.
pfu_read_key() {
local k rest
IFS= read -rsn1 k || return 1
case "$k" in
$'\e')
if IFS= read -rsn2 -t 0.05 rest; then
case "$rest" in
'[A') printf 'up\n' ;;
'[B') printf 'down\n' ;;
'[C') printf 'right\n' ;;
'[D') printf 'left\n' ;;
*) printf 'escape\n' ;;
esac
else
printf 'escape\n'
fi
;;
''|$'\r') printf 'enter\n' ;;
$'\x7f'|$'\b') printf 'backspace\n' ;;
' ') printf 'space\n' ;;
*) printf '%s\n' "$k" ;;
esac
}
# pfu_ui_read_line <initial>
# A minimal line editor built on pfu_read_key, with the result in
# PFU_LINE_RESULT. This exists instead of bash's own `read -r` because mixing
# line mode into a single-key interface breaks it: after one cooked-mode read
# the following `read -sn1` stops receiving keystrokes entirely.
PFU_LINE_RESULT=''
pfu_ui_read_line() {
local buf="${1:-}" key
PFU_LINE_RESULT=''
printf '%s' "$buf"
while true; do
key="$(pfu_read_key)" || { printf '\n'; return 1; }
case "$key" in
enter)
printf '\n'
PFU_LINE_RESULT="$buf"
return 0
;;
escape)
printf '\n'
return 1
;;
backspace)
if [[ -n $buf ]]; then
buf="${buf%?}"
printf '\b \b'
fi
;;
space)
buf+=' '
printf ' '
;;
up|down|left|right) ;;
*)
[[ ${#key} -eq 1 ]] || continue
buf+="$key"
printf '%s' "$key"
;;
esac
done
}
pfu_pause() {
printf '\n %s' "$(pfu_msg "Press any key to continue...")"
read -rsn1 _ || true
printf '\n'
}
# pfu_ui_confirm <question>
pfu_ui_confirm() {
local key
printf '\n %s %s ' "$1" "$(pfu_msg "[y/N]")"
key="$(pfu_read_key)" || return 1
printf '%s\n' "$key"
[[ $key == [yYjJ] ]]
}
# _pfu_row <label> <value>
# printf's %-28s pads by bytes, so a label containing "ü" comes out one column
# short. ${#s} counts characters in a UTF-8 locale, so the padding is computed
# here instead.
_pfu_row() {
local label="$1" value="$2" pad
pad=$(( 30 - ${#label} ))
(( pad < 0 )) && pad=0
printf ' %s%*s %s\n' "$label" "$pad" '' "$value"
}
_pfu_onoff() {
if [[ $1 == yes ]]; then
printf '%s%s%s' "$PFU_C_GREEN" "$(pfu_msg "ON")" "$PFU_C_RESET"
else
printf '%s%s%s' "$PFU_C_DIM" "$(pfu_msg "OFF")" "$PFU_C_RESET"
fi
}
_pfu_small_onoff() {
if [[ $1 == yes ]]; then
printf '%s%s%s' "$PFU_C_GREEN" "$(pfu_msg "on")" "$PFU_C_RESET"
else
printf '%s%s%s' "$PFU_C_DIM" "$(pfu_msg "off")" "$PFU_C_RESET"
fi
}
# pfu_value_label <Key> <value>
# How a setting's value reads in the menu.
pfu_value_label() {
case "$1:$2" in
Liveness:heavy) pfu_msg "strict (blink or turn your head)" ;;
Liveness:light) pfu_msg "basic (screens and phone edges)" ;;
Liveness:off) pfu_msg "off" ;;
Strictness:normal) pfu_msg "normal" ;;
Strictness:strict) pfu_msg "strict" ;;
Strictness:relaxed) pfu_msg "relaxed" ;;
BubbleStyle:full) pfu_msg "island with the face" ;;
BubbleStyle:minimal) pfu_msg "small pill with a lock" ;;
ScanSeconds:*) pfu_msg "%s seconds" "$2" ;;
Camera:auto) pfu_msg "automatic" ;;
*) printf '%s' "$2" ;;
esac
}
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
# pfu_ui_status
# Shared by the `status` subcommand and the menu header.
pfu_ui_status() {
local names='' i camera
pfu_config_load
pfu_status_load
_pfu_row "$(pfu_msg "Face unlock")" "$(_pfu_onoff "$CFG_ENABLED")"
printf '\n'
if [[ $PFU_ST_REACHABLE != yes ]]; then
_pfu_row "$(pfu_msg "Service")" "${PFU_C_YELLOW}$(pfu_msg "not running")${PFU_C_RESET}"
if [[ $CFG_ENABLED == yes ]]; then
printf '\n %s%s%s\n' "$PFU_C_DIM" "$(pfu_msg "Turning face unlock on again starts it.")" "$PFU_C_RESET"
fi
return 0
fi
pfu_faces_load
for i in "${!PFU_FACE_NAMES[@]}"; do
[[ ${PFU_FACE_ON[i]} == true ]] || continue
names="${names:+$names, }${PFU_FACE_NAMES[i]}"
done
if (( ${#PFU_FACE_IDS[@]} == 0 )); then
_pfu_row "$(pfu_msg "Faces")" "${PFU_C_YELLOW}$(pfu_msg "none set up yet")${PFU_C_RESET}"
else
_pfu_row "$(pfu_msg "Faces")" "${PFU_ST_FACES} ${PFU_C_DIM}(${names:-$(pfu_msg "all turned off")})${PFU_C_RESET}"
fi
if [[ $PFU_ST_CAMERA_PRESENT == true ]]; then
camera="${PFU_ST_CAMERA_NAME:-$PFU_ST_CAMERA}"
else
camera="${PFU_C_YELLOW}$(pfu_msg "none found")${PFU_C_RESET}"
fi
_pfu_row "$(pfu_msg "Camera")" "$camera"
_pfu_row "$(pfu_msg "Lock screen")" "$(_pfu_small_onoff "$( [[ $CFG_ENABLED == yes && $CFG_LOCK == yes ]] && echo yes || echo no)")"
_pfu_row "$(pfu_msg "sudo")" "$(_pfu_small_onoff "$(pfu_pam_enabled sudo && echo yes || echo no)")"
_pfu_row "$(pfu_msg "Admin prompts")" "$(_pfu_small_onoff "$(pfu_pam_enabled polkit-1 && echo yes || echo no)")"
_pfu_row "$(pfu_msg "Photo check")" "$(pfu_value_label Liveness "$CFG_LIVENESS")"
if (( PFU_ST_LOCKOUT > 0 )); then
_pfu_row "$(pfu_msg "Paused")" "${PFU_C_YELLOW}$(pfu_msg "for %d more minutes, or until the password is used" "$(( (PFU_ST_LOCKOUT + 59) / 60 ))")${PFU_C_RESET}"
fi
_pfu_row "$(pfu_msg "Last unlock")" "$(pfu_time_ago "$PFU_ST_LAST")"
if [[ $PFU_ST_MODELS != true ]]; then
printf '\n %s%s%s\n' "$PFU_C_RED" "$(pfu_msg "The recognition models are missing. Reinstall the package.")" "$PFU_C_RESET"
fi
}
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# Format: scope|Key|type|default|label-msgid|choices
# scope user (this user's file), sys (the system file, through sudo) or
# pam (the service of that name, through sudo)
# type bool, choice (cycles through the choices) or camera
PFU_SETTINGS=(
"user|LockScreen|bool|yes|Unlock the lock screen"
"user|ScanOnWake|bool|yes|Look when somebody comes back to the screen"
"user|ScanOnLock|bool|no|Look right after the screen locks"
"pam|sudo|bool|no|Use for sudo in a terminal"
"pam|polkit-1|bool|no|Use for admin prompts"
"sys|Liveness|choice|heavy|Photo check|heavy,light,off"
"sys|Strictness|choice|normal|How closely a face has to match|normal,strict,relaxed"
"sys|Attention|bool|yes|Only while looking at the screen"
"sys|Camera|camera|auto|Camera"
"sys|ScanSeconds|choice|5|How long one look lasts|3,4,5,6,8,10"
"sys|Adapt|bool|yes|Learn from every unlock"
"sys|SkipLidClosed|bool|yes|Not while the lid is closed"
"user|Bubble|bool|yes|Show the bubble at the top"
"user|BubbleStyle|choice|full|Bubble style|full,minimal"
"user|BubbleForPrompts|bool|yes|Bubble for sudo and admin prompts too"
)
# _pfu_setting_value <scope> <Key> <default>
# The current value, in PFU_SETTING_VALUE.
PFU_SETTING_VALUE=''
_pfu_setting_value() {
case "$1" in
user) _pfu_kv_lookup "$PFU_CONFIG" "$2" "$3"; PFU_SETTING_VALUE="$PFU_KV_VALUE" ;;
sys) _pfu_kv_lookup "$PFU_SYSCONFIG" "$2" "$3"; PFU_SETTING_VALUE="$PFU_KV_VALUE" ;;
pam) if pfu_pam_enabled "$2"; then PFU_SETTING_VALUE=yes; else PFU_SETTING_VALUE=no; fi ;;
esac
}
# _pfu_next_choice <current> <a,b,c>
_pfu_next_choice() {
local current="$1" list="$2" first='' found=0 c
local -a choices
IFS=',' read -r -a choices <<< "$list"
for c in "${choices[@]}"; do
[[ -z $first ]] && first="$c"
if (( found )); then
printf '%s\n' "$c"
return
fi
[[ $c == "$current" ]] && found=1
done
printf '%s\n' "$first"
}
# _pfu_next_camera <current>
_pfu_next_camera() {
local current="$1" i
pfu_cameras_load
local -a all=(auto "${PFU_CAM_PATHS[@]}")
for i in "${!all[@]}"; do
if [[ ${all[i]} == "$current" ]]; then
printf '%s\n' "${all[(i + 1) % ${#all[@]}]}"
return
fi
done
printf 'auto\n'
}
_pfu_camera_label() {
local value="$1" i
if [[ $value == auto ]]; then
for i in "${!PFU_CAM_PATHS[@]}"; do
if [[ ${PFU_CAM_PATHS[i]} == "$PFU_CAM_AUTO" ]]; then
pfu_msg "automatic (%s)" "${PFU_CAM_NAMES[i]}"
return
fi
done
pfu_msg "automatic"
return
fi
for i in "${!PFU_CAM_PATHS[@]}"; do
if [[ ${PFU_CAM_PATHS[i]} == "$value" ]]; then
printf '%s' "${PFU_CAM_NAMES[i]}"
[[ ${PFU_CAM_IR[i]} == true ]] && printf ' %s' "$(pfu_msg "(infrared)")"
return
fi
done
printf '%s %s' "$value" "$(pfu_msg "(not connected)")"
}
# _pfu_setting_change <scope> <Key> <type> <current> <choices>
_pfu_setting_change() {
local scope="$1" key="$2" type="$3" current="$4" choices="$5" next
case "$type" in
bool) if pfu_is_true "$current"; then next=no; else next=yes; fi ;;
choice) next="$(_pfu_next_choice "$current" "$choices")" ;;
camera) next="$(_pfu_next_camera "$current")" ;;
esac
case "$scope" in
user)
pfu_config_set "$key" "$next" || { pfu_bad "$(pfu_msg "Could not save the setting.")"; pfu_pause; }
;;
sys)
printf '\n'
pfu_ui_cooked pfu_root set "$key" "$next" || pfu_pause
PFU_KV_CACHE=()
;;
pam)
printf '\n'
if [[ $next == yes ]]; then
pfu_ui_cooked pfu_root pam-enable "$key" || pfu_pause
else
pfu_ui_cooked pfu_root pam-disable "$key" || pfu_pause
fi
# Remembered, so that turning face unlock off and on again brings
# it back.
case "$key" in
sudo) pfu_config_set Sudo "$next" ;;
polkit-1) pfu_config_set Polkit "$next" ;;
esac
;;
esac
}
# pfu_ui_settings
# A cursor list rather than a numbered menu. The frame is assembled in memory
# and written once, and everything constant is resolved before the loop.
pfu_ui_settings() {
local count=${#PFU_SETTINGS[@]}
local -a scopes=() keys=() types=() defaults=() labels=() choices=() values=()
local spec scope key type default label choice locale i frame row pad dirty=1 cursor=0 shown
locale="$(pfu_ui_locale)"
for spec in "${PFU_SETTINGS[@]}"; do
IFS='|' read -r scope key type default label choice <<< "$spec"
scopes+=("$scope"); keys+=("$key"); types+=("$type"); defaults+=("$default"); choices+=("$choice")
pfu_msg_into "$locale" "$label"
labels+=("$PFU_MSG_RESULT")
done
local title hint legend l_on l_off
pfu_msg_into "$locale" "Settings"; title="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "Up/Down: select, Space or Right: change, q: back"; hint="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "Settings marked * are for the whole computer and ask for your password."; legend="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "ON"; l_on="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "OFF"; l_off="$PFU_MSG_RESULT"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
pfu_cameras_load
while true; do
if (( dirty )); then
PFU_KV_CACHE=()
for i in "${!keys[@]}"; do
_pfu_setting_value "${scopes[i]}" "${keys[i]}" "${defaults[i]}"
values[i]="$PFU_SETTING_VALUE"
done
dirty=0
fi
frame="$clearseq"$'\n'"${PFU_C_BOLD}${PFU_C_BLUE} ${title}${PFU_C_RESET}"$'\n\n'
local marker selected="${PFU_C_BLUE}▸${PFU_C_RESET} " star
for i in "${!keys[@]}"; do
case "${types[i]}" in
bool)
if pfu_is_true "${values[i]}"; then
shown="${PFU_C_GREEN}${l_on}${PFU_C_RESET}"
else
shown="${PFU_C_DIM}${l_off}${PFU_C_RESET}"
fi
;;
camera) shown="$(_pfu_camera_label "${values[i]}")" ;;
*) shown="$(pfu_value_label "${keys[i]}" "${values[i]}")" ;;
esac
star=' '
[[ ${scopes[i]} != user ]] && star='*'
pad=$(( 44 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%s%*s %s' "$marker" "${labels[i]}" "${PFU_C_DIM}${star}${PFU_C_RESET}" "$pad" '' "$shown"
frame+="$row"$'\n'
# A gap between the groups: the lock screen, other prompts, how it
# checks, the bubble.
case "${keys[i]}" in
ScanOnLock|polkit-1|SkipLidClosed) frame+=$'\n' ;;
esac
done
frame+=$'\n'" ${PFU_C_DIM}${legend}${PFU_C_RESET}"$'\n'
frame+=" ${PFU_C_DIM}${hint}${PFU_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(pfu_read_key)" || return 0
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l)
_pfu_setting_change "${scopes[cursor]}" "${keys[cursor]}" "${types[cursor]}" "${values[cursor]}" "${choices[cursor]}"
dirty=1
;;
q|Q|escape) return 0 ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# Faces
# ---------------------------------------------------------------------------
pfu_ui_faces() {
local key frame row pad i cursor=0 count locale shown
locale="$(pfu_ui_locale)"
local title hint empty l_on l_off
pfu_msg_into "$locale" "Faces"; title="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "Up/Down: select, Space: on/off, r: rename, d: delete, a: add, q: back"; hint="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "No face is set up yet. Press a to add one."; empty="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "on"; l_on="$PFU_MSG_RESULT"
pfu_msg_into "$locale" "off"; l_off="$PFU_MSG_RESULT"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
while true; do
pfu_faces_load
count=${#PFU_FACE_IDS[@]}
(( cursor >= count )) && cursor=$(( count > 0 ? count - 1 : 0 ))
frame="$clearseq"$'\n'"${PFU_C_BOLD}${PFU_C_BLUE} ${title}${PFU_C_RESET}"$'\n\n'
if (( count == 0 )); then
frame+=" ${PFU_C_DIM}${empty}${PFU_C_RESET}"$'\n'
fi
local marker selected="${PFU_C_BLUE}▸${PFU_C_RESET} " samples
for i in "${!PFU_FACE_IDS[@]}"; do
if [[ ${PFU_FACE_ON[i]} == true ]]; then shown="${PFU_C_GREEN}${l_on}${PFU_C_RESET}"; else shown="${PFU_C_DIM}${l_off}${PFU_C_RESET}"; fi
samples="$(pfu_msg "%s samples, %s learned" "${PFU_FACE_SAMPLES[i]}" "${PFU_FACE_LEARNED[i]}")"
pad=$(( 30 - ${#PFU_FACE_NAMES[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s %s%s%s' "$marker" "${PFU_FACE_NAMES[i]}" "$pad" '' "$shown" "$PFU_C_DIM" "$samples" "$PFU_C_RESET"
frame+="$row"$'\n'
done
frame+=$'\n'" ${PFU_C_DIM}${hint}${PFU_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(pfu_read_key)" || return 0
case "$key" in
up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;;
space|enter)
(( count )) || continue
if [[ ${PFU_FACE_ON[cursor]} == true ]]; then
pfu_ctl disable "${PFU_FACE_IDS[cursor]}" > /dev/null
else
pfu_ctl enable "${PFU_FACE_IDS[cursor]}" > /dev/null
fi
;;
r|R)
(( count )) || continue
printf '\n %s ' "$(pfu_msg "New name:")"
if pfu_ui_read_line "${PFU_FACE_NAMES[cursor]}" && [[ -n $PFU_LINE_RESULT ]]; then
pfu_ctl rename "${PFU_FACE_IDS[cursor]}" "$PFU_LINE_RESULT" > /dev/null
fi
;;
d|D)
(( count )) || continue
if pfu_ui_confirm "$(pfu_msg "Delete \"%s\"?" "${PFU_FACE_NAMES[cursor]}")"; then
pfu_ctl remove "${PFU_FACE_IDS[cursor]}" > /dev/null
fi
;;
a|A)
pfu_ui_cooked pfu_do_setup
[[ -n $PFU_UI_NEEDS_ACK ]] && pfu_pause
PFU_UI_NEEDS_ACK=''
;;
q|Q|escape) return 0 ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# The menu
# ---------------------------------------------------------------------------
pfu_ui_menu() {
local choice
pfu_ui_term_raw
trap 'pfu_ui_term_restore' EXIT INT TERM
while true; do
clear 2>/dev/null || true
pfu_head " $PFU_PRETTY"
pfu_ui_status
printf '\n'
printf ' [1] %s\n' "$(pfu_msg "Turn face unlock on or off")"
printf ' [2] %s\n' "$(pfu_msg "Add a face")"
printf ' [3] %s\n' "$(pfu_msg "Faces")"
printf ' [4] %s\n' "$(pfu_msg "Settings")"
printf ' [5] %s\n' "$(pfu_msg "Try it")"
printf ' [q] %s\n' "$(pfu_msg "Quit")"
printf '\n > '
choice="$(pfu_read_key)" || {
printf '\n'; pfu_ui_term_restore; trap - EXIT INT TERM; return 0
}
case "$choice" in
enter|space|up|down|left|right|escape) choice='' ;;
esac
printf '%s\n' "$choice"
PFU_UI_NEEDS_ACK=''
case "$choice" in
1)
pfu_config_load
if [[ $CFG_ENABLED == yes ]]; then
pfu_ui_cooked pfu_do_disable
else
pfu_ui_cooked pfu_do_enable
fi
[[ -n $PFU_UI_NEEDS_ACK ]] && pfu_pause
;;
2)
pfu_ui_cooked pfu_do_setup
[[ -n $PFU_UI_NEEDS_ACK ]] && pfu_pause
;;
3) pfu_ui_faces ;;
4) pfu_ui_settings ;;
5)
printf '\n'
pfu_ui_cooked pfu_test
pfu_pause
;;
q|Q) pfu_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
# Anything else (Enter, arrow keys, stray characters) just
# redraws. Escape is deliberately not a quit key, so a mistyped
# arrow key cannot close the menu.
*) ;;
esac
done
}
+163
View File
@@ -0,0 +1,163 @@
# shellcheck shell=bash
#
# Putting face unlock in front of sudo and polkit's admin prompts, and taking
# it out again.
#
# Two services and nothing else. The lock screen does not go through PAM at all
# (see src/agent/lockcontroller.h), and the login screen stays with the
# password: logging in is what unlocks the wallet, and a face has no password
# to hand it.
#
# The line that goes in:
#
# -auth sufficient /usr/lib/security/pam_plasma_face_unlock.so
#
# "sufficient": a match lets the person in, anything else falls through to the
# lines below as if this one were not there. The dash makes PAM skip it
# quietly if the module ever goes missing, say because the package was removed
# without turning this off first. sudo keeps working either way.
#
# Where the service's file is:
# - /etc/pam.d/<service> exists: the line goes in before its first auth
# line, with a comment saying where it came from.
# - only the distribution's copy in /usr/lib/pam.d exists (Arch keeps
# polkit-1 there): a small /etc/pam.d/<service> is written that puts the
# line first and includes the distribution's file for everything else, so
# an update to that file still counts.
# Undoing takes out exactly those lines, or that file.
PFU_PAM_MARK="# plasma-face-unlock: the face first, the password if that does not work"
PFU_PAM_WRAPPER_MARK="# Written by plasma-face-unlock."
PFU_PAM_SERVICES=(sudo polkit-1)
# Overridable for the tests only; the root side never takes them from the
# environment (see the top of plasma-face-unlock).
PFU_PAM_ETC_DIR="${PFU_PAM_ETC_DIR:-/etc/pam.d}"
read -r -a PFU_PAM_VENDOR_DIRS <<< "${PFU_PAM_VENDOR_DIRS:-/usr/lib/pam.d /usr/share/pam.d /lib/pam.d}"
pfu_pam_etc() {
printf '%s/%s\n' "$PFU_PAM_ETC_DIR" "$1"
}
# pfu_pam_vendor <service>
# The distribution's own copy, when it keeps one outside /etc.
pfu_pam_vendor() {
local dir
for dir in "${PFU_PAM_VENDOR_DIRS[@]}"; do
if [[ -f $dir/$1 ]]; then
printf '%s\n' "$dir/$1"
return 0
fi
done
return 1
}
pfu_pam_line() {
printf -- '-auth sufficient %s\n' "$PFU_PAM_MODULE"
}
# pfu_pam_enabled <service>
pfu_pam_enabled() {
local file
file="$(pfu_pam_etc "$1")"
[[ -r $file ]] && grep -q 'pam_plasma_face_unlock\.so' "$file"
}
# pfu_pam_insert <file>
# Prints the file with the line added before its first auth line. Debian and
# Ubuntu have none in sudo's file, only "@include common-auth", and that
# counts as one: after it the password has already been asked for. A file
# with neither (which would be odd for either service) gets it at the end.
pfu_pam_insert() {
awk -v mark="$PFU_PAM_MARK" -v line="$(pfu_pam_line)" '
!done && ($0 ~ /^[[:space:]]*-?auth[[:space:]]/ || $0 ~ /^[[:space:]]*@include[[:space:]]+common-auth([[:space:]]|$)/) {
print mark
print line
done = 1
}
{ print }
END {
if (!done) {
print mark
print line
}
}
' "$1"
}
# pfu_pam_remove_lines <file>
# Prints the file without our comment and our line.
pfu_pam_remove_lines() {
awk -v mark="$PFU_PAM_MARK" '
$0 == mark { next }
/pam_plasma_face_unlock\.so/ { next }
{ print }
' "$1"
}
pfu_pam_wrapper() {
local vendor="$1"
printf '#%%PAM-1.0\n'
printf '%s The face first, then everything\n' "$PFU_PAM_WRAPPER_MARK"
printf '# %s does for this service. Removed again by\n' "$vendor"
printf '# "plasma-face-unlock disable" or from its settings.\n\n'
pfu_pam_line
printf 'auth include %s\n' "$vendor"
printf 'account include %s\n' "$vendor"
printf 'password include %s\n' "$vendor"
printf 'session include %s\n' "$vendor"
}
# _pfu_pam_replace <file> <content>
# Writes the new file next to the old one and swaps it in, with the old one's
# owner and mode. A half-written PAM file is a locked-out machine.
_pfu_pam_replace() {
local file="$1" content="$2" tmp
tmp="$(mktemp "${file}.pfu.XXXXXX")" || return 1
printf '%s\n' "$content" > "$tmp" || { rm -f "$tmp"; return 1; }
if [[ -e $file ]]; then
chown --reference="$file" "$tmp" 2>/dev/null
chmod --reference="$file" "$tmp" 2>/dev/null
else
chmod 0644 "$tmp"
fi
mv -f "$tmp" "$file"
}
# pfu_pam_enable <service> (root)
pfu_pam_enable() {
local service="$1" file vendor content
file="$(pfu_pam_etc "$service")"
[[ -e $PFU_PAM_MODULE ]] || { pfu_bad "$(pfu_msg "The PAM module is not installed at %s." "$PFU_PAM_MODULE")"; return 1; }
pfu_pam_enabled "$service" && return 0
if [[ -f $file ]]; then
content="$(pfu_pam_insert "$file")" || return 1
elif vendor="$(pfu_pam_vendor "$service")"; then
content="$(pfu_pam_wrapper "$vendor")"
else
pfu_bad "$(pfu_msg "There is no PAM configuration for %s on this system." "$service")"
return 1
fi
_pfu_pam_replace "$file" "$content"
}
# pfu_pam_disable <service> (root)
pfu_pam_disable() {
local service="$1" file content
file="$(pfu_pam_etc "$service")"
pfu_pam_enabled "$service" || return 0
if head -n 3 "$file" | grep -qF "$PFU_PAM_WRAPPER_MARK"; then
# Ours from the first line to the last. Without it the distribution's
# own copy is in charge again.
rm -f -- "$file"
return
fi
content="$(pfu_pam_remove_lines "$file")" || return 1
_pfu_pam_replace "$file" "$content"
}
+122
View File
@@ -0,0 +1,122 @@
# shellcheck shell=bash
#
# The few things that need root, and how the menu gets them done.
#
# The menu runs as the user. When it needs root it runs this same program
# again through sudo (or run0, or doas) with --root and one verb, the way
# cachy-auto-update does, so the password is typed into the terminal the user
# is already looking at. The verbs are all there is: there is no way to hand
# the root side a command of one's own.
#
# --root set <Key> <Value> one line of /etc/plasma-face-unlock/config
# --root pam-enable <service> sudo or polkit-1, see pam.sh
# --root pam-disable <service>
# --root socket-enable start the daemon's socket, and at boot
# --root socket-disable
PFU_SELF="${PFU_SELF:-$(readlink -f "${BASH_SOURCE[1]:-$0}")}"
# What each system setting may be set to. Anything else is refused on the root
# side, whatever the menu sent.
declare -A PFU_SYS_VALID=(
[Camera]='^(auto|/dev/video[0-9]+)$'
[Liveness]='^(off|light|heavy)$'
[Strictness]='^(relaxed|normal|strict)$'
[Attention]='^(yes|no)$'
[ScanSeconds]='^([2-9]|1[0-5])$'
[Adapt]='^(yes|no)$'
[SkipLidClosed]='^(yes|no)$'
[MaxFailures]='^([1-9]|1[0-9]|20)$'
[LockoutMinutes]='^[1-9][0-9]{0,3}$'
)
# pfu_root <verb> [args...]
pfu_root() {
local candidate
if [[ $EUID -eq 0 ]]; then
pfu_root_verb "$@"
return
fi
for candidate in sudo run0 doas; do
if pfu_have "$candidate"; then
"$candidate" "$PFU_SELF" --root "$@"
return
fi
done
pfu_bad "$(pfu_msg "This needs root, and neither sudo, run0 nor doas is installed.")"
return 1
}
pfu_root_verb() {
local verb="${1:-}"
[[ $# -gt 0 ]] && shift
if [[ $EUID -ne 0 ]]; then
pfu_bad "$(pfu_msg "This command has to run as root.")"
return 2
fi
case "$verb" in
set)
local key="${1:-}" value="${2:-}"
if [[ -z ${PFU_SYS_VALID[$key]+set} || ! $value =~ ${PFU_SYS_VALID[$key]} ]]; then
pfu_bad "$(pfu_msg "Not a valid setting: %s=%s" "$key" "$value")"
return 1
fi
pfu_kv_set "$PFU_SYSCONFIG" "$key" "$value" "$PFU_PRETTY (system settings)" || return 1
chmod 0644 "$PFU_SYSCONFIG"
;;
pam-enable|pam-disable)
local service="${1:-}" known=0 s
for s in "${PFU_PAM_SERVICES[@]}"; do
[[ $s == "$service" ]] && known=1
done
(( known )) || { pfu_bad "$(pfu_msg "Not a service this can be used for: %s" "$service")"; return 1; }
if [[ $verb == pam-enable ]]; then
pfu_pam_enable "$service"
else
pfu_pam_disable "$service"
fi
;;
socket-enable)
systemctl enable --now "$PFU_UNIT_SOCKET" > /dev/null 2>&1
;;
socket-disable)
systemctl disable --now "$PFU_UNIT_SOCKET" > /dev/null 2>&1
;;
*)
pfu_bad "$(pfu_msg "Unknown command: %s" "$verb")"
return 1
;;
esac
}
# ---------------------------------------------------------------------------
# The two services
# ---------------------------------------------------------------------------
pfu_socket_enabled() {
systemctl is-enabled --quiet "$PFU_UNIT_SOCKET" 2>/dev/null
}
pfu_agent_available() {
pfu_have systemctl && [[ -n ${XDG_RUNTIME_DIR:-} ]]
}
pfu_agent_enabled() {
systemctl --user is-enabled --quiet "$PFU_UNIT_AGENT" 2>/dev/null
}
pfu_agent_running() {
systemctl --user is-active --quiet "$PFU_UNIT_AGENT" 2>/dev/null
}
pfu_agent_enable() {
systemctl --user daemon-reload > /dev/null 2>&1 || true
systemctl --user enable --now "$PFU_UNIT_AGENT" > /dev/null 2>&1
}
pfu_agent_disable() {
systemctl --user disable --now "$PFU_UNIT_AGENT" > /dev/null 2>&1 || true
}
+350
View File
@@ -0,0 +1,350 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// pam_plasma_face_unlock: face unlock for sudo and for admin prompts.
//
// All the vision is in the daemon. This asks it to scan for the user and
// turns the answer into a PAM result, and it is meant to sit first in a stack
// as "auth sufficient": a match lets the person in, anything else falls
// through to the password as if the module was not there.
//
// It refuses to be useful in exactly the places where a camera is the wrong
// witness:
// - a remote login (SSH, a remote host in PAM_RHOST). Whoever is in front
// of the camera is not the person typing.
// - a user who is not sitting at the machine right now, with an active
// session on a seat.
// In both cases it returns PAM_IGNORE without touching the camera.
//
// Options:
// purpose=sudo|polkit|other what the bubble says (default: from the
// service name)
// socket=PATH the daemon's socket (for development)
// timeout=SECONDS give up waiting for the daemon after this
// debug log to the auth log what happened
#define _GNU_SOURCE
#define PAM_SM_AUTH
#include "buildconfig.h"
#include <security/pam_ext.h>
#include <security/pam_modules.h>
#include <errno.h>
#include <libintl.h>
#include <poll.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <syslog.h>
#include <time.h>
#include <unistd.h>
#ifdef HAVE_SYSTEMD
#include <pwd.h>
#include <systemd/sd-login.h>
#endif
#define DOMAIN PFU_NAME
#define _(s) dgettext(DOMAIN, s)
struct options {
const char *socket;
const char *purpose;
int timeout;
bool debug;
};
static void parse_options(struct options *o, int argc, const char **argv)
{
o->socket = PFU_SOCKET;
o->purpose = NULL;
o->timeout = 25;
o->debug = false;
for (int i = 0; i < argc; ++i) {
if (strncmp(argv[i], "socket=", 7) == 0) {
o->socket = argv[i] + 7;
} else if (strncmp(argv[i], "purpose=", 8) == 0) {
o->purpose = argv[i] + 8;
} else if (strncmp(argv[i], "timeout=", 8) == 0) {
o->timeout = atoi(argv[i] + 8);
if (o->timeout < 3 || o->timeout > 120) {
o->timeout = 25;
}
} else if (strcmp(argv[i], "debug") == 0) {
o->debug = true;
}
}
}
static long long now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static bool nonempty(const char *s)
{
return s && *s;
}
// Whoever types this is not whoever sits in front of the camera.
static bool is_remote(pam_handle_t *pamh)
{
const void *rhost = NULL;
if (pam_get_item(pamh, PAM_RHOST, &rhost) == PAM_SUCCESS && nonempty(rhost) && strcmp(rhost, "localhost") != 0) {
return true;
}
static const char *const vars[] = {"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"};
for (size_t i = 0; i < sizeof(vars) / sizeof(vars[0]); ++i) {
if (nonempty(getenv(vars[i])) || nonempty(pam_getenv(pamh, vars[i]))) {
return true;
}
}
#ifdef HAVE_SYSTEMD
char *session = NULL;
if (sd_pid_get_session(0, &session) >= 0 && session) {
const bool remote = sd_session_is_remote(session) > 0;
free(session);
if (remote) {
return true;
}
}
#endif
return false;
}
// The person has to be at the machine: an active session on a seat.
static bool is_at_seat(const char *user)
{
#ifdef HAVE_SYSTEMD
struct passwd pw, *result = NULL;
char buf[4096];
if (getpwnam_r(user, &pw, buf, sizeof(buf), &result) != 0 || !result) {
return false;
}
// The number of seats the user is active on, whatever they are called.
return sd_uid_get_seats(result->pw_uid, 1, NULL) > 0;
#else
(void)user;
return true;
#endif
}
static int connect_daemon(const struct options *o, pam_handle_t *pamh)
{
struct sockaddr_un addr = {.sun_family = AF_UNIX};
if (strlen(o->socket) >= sizeof(addr.sun_path)) {
return -1;
}
strcpy(addr.sun_path, o->socket);
const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) {
return -1;
}
struct timeval tv = {.tv_sec = 3};
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
if (o->debug) {
pam_syslog(pamh, LOG_DEBUG, "cannot reach the daemon at %s: %s", o->socket, strerror(errno));
}
close(fd);
return -1;
}
// Only root may answer for root. When this runs as somebody else (the
// tests), a daemon of that same user is no less trusted than the process
// asking.
struct ucred cred;
socklen_t len = sizeof(cred);
if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0 || (cred.uid != 0 && cred.uid != geteuid())) {
pam_syslog(pamh, LOG_WARNING, "refusing a face unlock daemon that does not run as root");
close(fd);
return -1;
}
return fd;
}
// The daemon's answers are compact JSON objects with plain values. It runs as
// root and is the one party here that is trusted, so this only has to be
// correct for well formed input and safe for anything else.
static bool json_string(const char *line, const char *key, char *out, size_t size)
{
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\":\"", key);
const char *p = strstr(line, pattern);
if (!p || size == 0) {
return false;
}
p += strlen(pattern);
size_t n = 0;
while (*p && *p != '"' && n + 1 < size) {
if (*p == '\\' && p[1]) {
++p;
}
out[n++] = *p++;
}
out[n] = '\0';
return true;
}
static bool json_true(const char *line, const char *key)
{
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\":true", key);
return strstr(line, pattern) != NULL;
}
static void say(pam_handle_t *pamh, int flags, const char *message)
{
if (!(flags & PAM_SILENT)) {
pam_info(pamh, "%s", message);
}
}
__attribute__((visibility("default"))) PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, const char **argv)
{
struct options o;
parse_options(&o, argc, argv);
bindtextdomain(DOMAIN, PFU_LOCALEDIR);
const char *user = NULL;
if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS || !nonempty(user)) {
return PAM_IGNORE;
}
const void *service = NULL;
pam_get_item(pamh, PAM_SERVICE, &service);
const char *purpose = o.purpose;
if (!purpose) {
purpose = !service ? "other"
: strncmp(service, "sudo", 4) == 0 ? "sudo"
: strcmp(service, "polkit-1") == 0 ? "polkit"
: "other";
}
if (is_remote(pamh)) {
if (o.debug) {
pam_syslog(pamh, LOG_DEBUG, "remote session, not scanning for %s", user);
}
return PAM_IGNORE;
}
if (!is_at_seat(user)) {
if (o.debug) {
pam_syslog(pamh, LOG_DEBUG, "%s is not at the machine, not scanning", user);
}
return PAM_IGNORE;
}
const int fd = connect_daemon(&o, pamh);
if (fd < 0) {
return PAM_IGNORE;
}
char request[512];
const int len = snprintf(request, sizeof(request), "{\"cmd\":\"verify\",\"user\":\"%s\",\"purpose\":\"%s\"}\n", user, purpose);
if (len <= 0 || (size_t)len >= sizeof(request) || strpbrk(user, "\"\\") || write(fd, request, (size_t)len) != len) {
close(fd);
return PAM_IGNORE;
}
const long long deadline = now_ms() + (long long)o.timeout * 1000;
char buf[8192];
size_t used = 0;
int rc = PAM_AUTHINFO_UNAVAIL;
bool done = false;
bool told = false;
while (!done) {
const long long left = deadline - now_ms();
if (left <= 0) {
break;
}
struct pollfd pfd = {.fd = fd, .events = POLLIN};
const int ready = poll(&pfd, 1, (int)left);
if (ready < 0 && errno == EINTR) {
// Ctrl+C in sudo. Stop the camera and go on to the password.
break;
}
if (ready <= 0) {
break;
}
const ssize_t got = read(fd, buf + used, sizeof(buf) - 1 - used);
if (got <= 0) {
break;
}
used += (size_t)got;
buf[used] = '\0';
char *line = buf;
char *nl;
while ((nl = strchr(line, '\n'))) {
*nl = '\0';
char event[32] = "", value[128] = "";
json_string(line, "event", event, sizeof(event));
if (strcmp(event, "started") == 0 && !told) {
told = true;
say(pamh, flags, _("Look at the camera to unlock."));
} else if (strcmp(event, "hint") == 0 && json_string(line, "hint", value, sizeof(value))) {
if (strcmp(value, "blink") == 0) {
say(pamh, flags, _("Blink, or turn your head a little."));
} else if (strcmp(value, "look") == 0) {
say(pamh, flags, _("Look at the screen."));
} else if (strcmp(value, "closer") == 0) {
say(pamh, flags, _("Move closer to the camera."));
} else if (strcmp(value, "light") == 0) {
say(pamh, flags, _("It is too dark to see your face."));
}
} else if (strcmp(event, "result") == 0) {
done = true;
json_string(line, "reason", value, sizeof(value));
if (json_true(line, "ok")) {
rc = PAM_SUCCESS;
pam_syslog(pamh, LOG_NOTICE, "face recognised for %s (%s)", user, purpose);
} else if (strcmp(value, "lockout") == 0 || strstr(line, "\"lockout\":")) {
rc = PAM_AUTH_ERR;
say(pamh, flags, _("Face unlock is paused after too many tries. Use your password."));
} else if (strcmp(value, "mismatch") == 0 || strcmp(value, "spoof") == 0 || strcmp(value, "liveness") == 0) {
rc = PAM_AUTH_ERR;
pam_syslog(pamh, LOG_NOTICE, "face not recognised for %s (%s)", user, value);
say(pamh, flags, _("Face not recognised."));
} else if (strcmp(value, "no-face") == 0 || strcmp(value, "attention") == 0 || strcmp(value, "quality") == 0) {
rc = PAM_AUTH_ERR;
} else {
// Not set up, no camera, lid shut, busy: face unlock is
// simply not available right now.
rc = PAM_AUTHINFO_UNAVAIL;
if (o.debug) {
pam_syslog(pamh, LOG_DEBUG, "face unlock unavailable for %s: %s", user, value);
}
}
break;
}
line = nl + 1;
}
// Keep what is left of an unfinished line.
used = strlen(line);
memmove(buf, line, used + 1);
if (used >= sizeof(buf) - 1) {
break;
}
}
close(fd);
return rc;
}
__attribute__((visibility("default"))) PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags, int argc, const char **argv)
{
(void)pamh;
(void)flags;
(void)argc;
(void)argv;
return PAM_IGNORE;
}
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
#
# plasma-face-unlock: face unlock for KDE Plasma
#
# 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
# instead of a password, with a check that it is a face and not a photo of one.
#
# This is the front end: the menu and the commands. The camera, the face data
# and the decision are the daemon's (plasma-face-unlockd, running as root),
# the lock screen and the bubble at the top of the screen are the agent's
# (plasma-face-unlock-agent, in the session), and sudo and polkit reach the
# daemon through a PAM module. See the man page for how the pieces fit.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
set -uo pipefail
PFU_SELF="$(readlink -f "${BASH_SOURCE[0]}")"
# Run as root (through sudo from the menu), only what was installed counts.
# An environment that points the libraries somewhere else is ignored then.
if [[ $EUID -eq 0 ]]; then
unset PFU_LIBDIR PFU_LIBEXECDIR PFU_LOCALEDIR PFU_PAMDIR PFU_CTL PFU_AGENT PFU_PAM_MODULE PFU_SYSCONFIG \
PFU_PAM_ETC_DIR PFU_PAM_VENDOR_DIRS
fi
PFU_LIBDIR="${PFU_LIBDIR:-@LIBDIR@}"
for _mod in common config daemon pam system menu; do
# shellcheck source=/dev/null
if ! source "$PFU_LIBDIR/$_mod.sh"; then
printf 'plasma-face-unlock: cannot load %s/%s.sh\n' "$PFU_LIBDIR" "$_mod" >&2
exit 14
fi
done
unset _mod
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
# The daemon is started by its socket. Enabling the socket is the one thing
# that needs root once per machine.
pfu_ensure_service() {
pfu_status_load && return 0
if ! pfu_socket_enabled; then
pfu_say " $(pfu_msg "Face unlock's service has to be switched on once for this computer. That needs your password.")"
pfu_root socket-enable || return 1
sleep 0.3
fi
pfu_status_load && return 0
pfu_bad "$(pfu_reason_text unreachable)"
pfu_note "$(pfu_msg "Check it with: systemctl status %s" "$PFU_UNIT_SOCKET")"
return 1
}
pfu_do_setup() {
local name="${1:-}" rc
pfu_ensure_service || return 1
if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
pfu_bad "$(pfu_msg "Setting up a face needs the camera picture on screen. Run this inside your Plasma session.")"
return 1
fi
pfu_say " $(pfu_msg "The setup window is open. Follow it there.")"
if [[ -n $name ]]; then
"$PFU_AGENT" --enroll --name "$name" > /dev/null 2>&1
else
"$PFU_AGENT" --enroll > /dev/null 2>&1
fi
rc=$?
if (( rc == 0 )); then
pfu_ok "$(pfu_msg "The face is set up.")"
pfu_config_load
if [[ $CFG_ENABLED != yes ]]; then
pfu_note "$(pfu_msg "Face unlock is still off. Turn it on with [1] or \`%s enable\`." "$PFU_NAME")"
fi
return 0
fi
pfu_note "$(pfu_msg "No face was added.")"
return 1
}
pfu_do_enable() {
pfu_ensure_service || return 1
pfu_faces_load
if (( ${#PFU_FACE_IDS[@]} == 0 )); then
pfu_say " $(pfu_msg "First, set up your face.")"
pfu_do_setup || return 1
fi
pfu_config_set Enabled yes || { pfu_bad "$(pfu_msg "Could not save the setting.")"; return 1; }
pfu_config_load
if pfu_agent_available; then
pfu_agent_enable || pfu_bad "$(pfu_msg "Could not start the lock screen agent.")"
else
pfu_note "$(pfu_msg "No systemd user session, so the lock screen agent was not started.")"
fi
# What was on the last time face unlock was on.
[[ $CFG_SUDO == yes ]] && ! pfu_pam_enabled sudo && pfu_root pam-enable sudo
[[ $CFG_POLKIT == yes ]] && ! pfu_pam_enabled polkit-1 && pfu_root pam-enable polkit-1
pfu_ok "$(pfu_msg "Face unlock is on. Lock the screen and look at it to try.")"
if [[ $CFG_SUDO != yes && $CFG_POLKIT != yes ]]; then
pfu_note "$(pfu_msg "It can do sudo and admin prompts too. See Settings.")"
fi
}
pfu_do_disable() {
pfu_config_set Enabled no || { pfu_bad "$(pfu_msg "Could not save the setting.")"; return 1; }
pfu_agent_available && pfu_agent_disable
local service
for service in "${PFU_PAM_SERVICES[@]}"; do
if pfu_pam_enabled "$service"; then
pfu_root pam-disable "$service" || true
fi
done
pfu_ok "$(pfu_msg "Face unlock is off. Your faces are kept; delete them under Faces.")"
}
pfu_do_status() {
pfu_head " $PFU_PRETTY"
pfu_ui_status
printf '\n'
}
pfu_do_faces() {
local i state
pfu_status_load || { pfu_bad "$(pfu_reason_text unreachable)"; return 1; }
pfu_faces_load
if (( ${#PFU_FACE_IDS[@]} == 0 )); then
pfu_note "$(pfu_msg "No face is set up yet.")"
return 0
fi
for i in "${!PFU_FACE_IDS[@]}"; do
if [[ ${PFU_FACE_ON[i]} == true ]]; then state="$(pfu_msg "on")"; else state="$(pfu_msg "off")"; fi
printf ' %s %-24s %-4s %s\n' "${PFU_FACE_IDS[i]}" "${PFU_FACE_NAMES[i]}" "$state" \
"$(pfu_msg "%s samples, %s learned" "${PFU_FACE_SAMPLES[i]}" "${PFU_FACE_LEARNED[i]}")"
done
}
pfu_do_remove() {
local id="${1:-}" line
[[ -n $id ]] || { pfu_bad "$(pfu_msg "Which face? See \`%s faces\` for the ids." "$PFU_NAME")"; return 1; }
line="$(pfu_ctl remove "$id" | tail -n1)"
_pfu_fields "$line"
if [[ ${PFU_F[ok]:-} == true ]]; then
pfu_ok "$(pfu_msg "Deleted.")"
else
pfu_bad "$(pfu_reason_text "${PFU_F[reason]:-unreachable}")"
return 1
fi
}
pfu_do_help() {
cat <<- EOF
$PFU_PRETTY $PFU_VERSION
$(pfu_msg "Usage: plasma-face-unlock [command]")
$(pfu_msg "Commands:")
enable $(pfu_msg "Turn face unlock on")
disable $(pfu_msg "Turn it off (faces are kept)")
setup [NAME] $(pfu_msg "Add a face")
faces $(pfu_msg "List the faces")
remove ID $(pfu_msg "Delete a face")
test $(pfu_msg "Look at the camera and see what it sees")
status $(pfu_msg "Show what is on")
-h, --help $(pfu_msg "Show this help")
-V, --version $(pfu_msg "Show the version")
$(pfu_msg "Without a command an interactive menu is shown.")
EOF
}
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
main() {
local cmd="${1:-}"
[[ $# -gt 0 ]] && shift
case "$cmd" in
--root) pfu_root_verb "$@"; return ;;
esac
# Face data and settings are per user; root has neither a lock screen
# nor a face of its own to set up.
if [[ $EUID -eq 0 && -z ${PFU_ALLOW_ROOT:-} ]]; then
pfu_bad "$(pfu_msg "Run this as your own user, not as root. It asks for your password when it needs to.")"
exit 2
fi
case "$cmd" in
enable) pfu_do_enable ;;
disable) pfu_do_disable ;;
setup|add|enroll) pfu_do_setup "$@" ;;
faces|list) pfu_do_faces ;;
remove|delete) pfu_do_remove "$@" ;;
test|try) pfu_ensure_service && pfu_test ;;
status) pfu_do_status ;;
-h|--help|help) pfu_do_help ;;
-V|--version) printf '%s %s\n' "$PFU_NAME" "$PFU_VERSION" ;;
'') pfu_ui_menu ;;
*)
pfu_bad "$(pfu_msg "Unknown command: %s" "$cmd")"
printf '\n'
pfu_do_help
exit 1
;;
esac
}
main "$@"
+12
View File
@@ -0,0 +1,12 @@
// Filled in by CMake. The one place the compiled programs learn where the
// rest of the installation is.
#pragma once
#define PFU_VERSION "@PFU_VERSION@"
#define PFU_NAME "plasma-face-unlock"
#define PFU_LIBEXECDIR "@PFU_LIBEXECDIR@"
#define PFU_MODELDIR "@PFU_MODELDIR@"
#define PFU_LOCALEDIR "@PFU_LOCALEDIR@"
#define PFU_SOCKET "@PFU_SOCKET@"
#define PFU_STATEDIR "@PFU_STATEDIR@"
#define PFU_CONFIG "@PFU_CONFIG@"