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;
|
||||
};
|
||||
Reference in new issue
Block a user