feat: add plasma-face-unlock
This commit is contained in:
commit
f671acc93b
105 files changed
+13862
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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())}};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "$@"
|
||||
@@ -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@"
|
||||
Reference in new issue
Block a user