feat: ask which lock screen to use on hyprland and niri

This commit is contained in:
Felitendo committed 2026-09-26 19:37:06 +02:00
1 parent 5699158bb1
commit 1da82f21fc
34 files changed
+5319 -1609

No files matched your search

+2 -1
View File
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The lock screens that are programs of their own and take signals from
// outside: hyprlock, swaylock and gtklock open on SIGUSR1.
// outside: hyprlock, swaylock and gtklock open on SIGUSR1, hyprlock reads
// its labels again on SIGUSR2.
#pragma once
+647
View File
@@ -0,0 +1,647 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "lockpreview.h"
#include <KLocalizedString>
#include <QDateTime>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QProcess>
#include <QRegularExpression>
#include <QPointF>
#include <QStandardPaths>
#include <QTime>
#include <QVariantMap>
#include <algorithm>
#include <pwd.h>
#include <unistd.h>
namespace
{
QString readFile(const QString &path)
{
QFile f(path);
return f.open(QIODevice::ReadOnly | QIODevice::Text) ? QString::fromUtf8(f.readAll()) : QString();
}
QString expandHome(const QString &path)
{
return path == u"~" ? QDir::homePath() : path.startsWith(u"~/") ? QDir::homePath() + path.mid(1) : path;
}
QString configHome()
{
return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation);
}
// The first file of name under the user's and then the system's config
// directories, as hyprlock and swaylock look for theirs.
QString findConfig(const QString &name)
{
QStringList dirs{configHome()};
dirs += QStandardPaths::standardLocations(QStandardPaths::GenericConfigLocation);
for (const QString &dir : std::as_const(dirs)) {
if (QFileInfo::exists(dir + u'/' + name)) {
return dir + u'/' + name;
}
}
return {};
}
bool truthy(const QString &value)
{
return value == u"1" || value.compare(u"true", Qt::CaseInsensitive) == 0 || value.compare(u"yes", Qt::CaseInsensitive) == 0
|| value.compare(u"on", Qt::CaseInsensitive) == 0;
}
// ---------------------------------------------------------------------------
// hyprlang, as far as a lock screen's config needs it
struct Section {
QString name;
QHash<QString, QString> values;
};
// Sections, $variables, source = (with ~ and wildcards, relative to the
// file), and # comments, where ## stands for a #.
class Hyprlang
{
public:
void parse(const QString &path, int depth = 0);
QList<Section> sections;
private:
QString expand(const QString &value) const;
QHash<QString, QString> m_vars;
QList<qsizetype> m_open;
};
QString stripComment(const QString &line)
{
QString out;
for (qsizetype i = 0; i < line.size(); ++i) {
if (line.at(i) == u'#') {
if (i + 1 < line.size() && line.at(i + 1) == u'#') {
out += u'#';
++i;
continue;
}
break;
}
out += line.at(i);
}
return out.trimmed();
}
void Hyprlang::parse(const QString &path, int depth)
{
if (depth > 8) {
return;
}
const QString dir = QFileInfo(path).absolutePath();
const QStringList lines = readFile(path).split(u'\n');
for (const QString &raw : lines) {
const QString line = stripComment(raw);
if (line.isEmpty()) {
continue;
}
if (line == u"}") {
if (!m_open.isEmpty()) {
m_open.removeLast();
}
continue;
}
if (line.endsWith(u'{')) {
sections.append({line.chopped(1).trimmed(), {}});
m_open.append(sections.size() - 1);
continue;
}
const qsizetype eq = line.indexOf(u'=');
if (eq < 0) {
continue;
}
const QString key = line.left(eq).trimmed();
const QString value = expand(line.mid(eq + 1).trimmed());
if (key.startsWith(u'$')) {
m_vars.insert(key.mid(1), value);
} else if (key == u"source" && m_open.isEmpty()) {
const QFileInfo pattern(QDir(dir).absoluteFilePath(expandHome(value)));
const QStringList matches = QDir(pattern.absolutePath()).entryList({pattern.fileName()}, QDir::Files, QDir::Name);
for (const QString &match : matches) {
parse(pattern.absolutePath() + u'/' + match, depth + 1);
}
} else if (!m_open.isEmpty()) {
sections[m_open.last()].values.insert(key, value);
}
}
}
QString Hyprlang::expand(const QString &value) const
{
if (!value.contains(u'$') || m_vars.isEmpty()) {
return value;
}
// Longest first, so $font does not eat the start of $fontsize.
QStringList names = m_vars.keys();
std::sort(names.begin(), names.end(), [](const QString &a, const QString &b) {
return a.size() > b.size();
});
QString out = value;
for (const QString &name : std::as_const(names)) {
out.replace(u'$' + name, m_vars.value(name));
}
return out;
}
// ---------------------------------------------------------------------------
// Values
// "rgba(a, b) rgba(c) 45deg": the colours and the angle.
QVariantMap gradient(const QString &value)
{
QVariantList colors;
double angle = 0;
QString token;
int depth = 0;
const auto flush = [&] {
const QString t = token.trimmed();
token.clear();
if (t.endsWith(u"deg")) {
angle = t.chopped(3).toDouble();
} else if (const QColor c = LockPreview::color(t); c.isValid()) {
colors.append(c);
}
};
for (const QChar ch : value) {
if (ch == u'(') {
++depth;
} else if (ch == u')') {
--depth;
}
if (ch.isSpace() && depth == 0) {
flush();
} else {
token += ch;
}
}
flush();
return {{QStringLiteral("colors"), colors}, {QStringLiteral("angle"), angle}};
}
// "x, y", each in pixels or percent of the screen.
QPointF layout(const QString &value, const QSizeF &screen)
{
const QStringList parts = value.split(u',');
const auto one = [](const QString &part, qreal whole) {
const QString s = part.trimmed();
return s.endsWith(u'%') ? s.chopped(1).toDouble() / 100.0 * whole : s.toDouble();
};
return {one(parts.value(0), screen.width()), one(parts.value(1), screen.height())};
}
// A Pango font description ("Noto Sans Light 12") as family, weight and
// slant.
void font(const QString &description, QVariantMap &into)
{
static const QHash<QString, int> weights{
{QStringLiteral("thin"), 100}, {QStringLiteral("ultra-light"), 200}, {QStringLiteral("extra-light"), 200},
{QStringLiteral("ultralight"), 200}, {QStringLiteral("extralight"), 200}, {QStringLiteral("light"), 300},
{QStringLiteral("semi-light"), 350}, {QStringLiteral("book"), 380}, {QStringLiteral("regular"), 400},
{QStringLiteral("normal"), 400}, {QStringLiteral("medium"), 500}, {QStringLiteral("semi-bold"), 600},
{QStringLiteral("semibold"), 600}, {QStringLiteral("demi-bold"), 600}, {QStringLiteral("demibold"), 600},
{QStringLiteral("bold"), 700}, {QStringLiteral("ultra-bold"), 800}, {QStringLiteral("extra-bold"), 800},
{QStringLiteral("extrabold"), 800}, {QStringLiteral("heavy"), 900}, {QStringLiteral("black"), 900},
};
QStringList words = description.section(u',', 0, 0).split(u' ', Qt::SkipEmptyParts);
int weight = 400;
bool italic = false;
while (words.size() > 1) {
const QString word = words.last().toLower();
bool number = false;
word.toDouble(&number);
if (number) {
words.removeLast();
} else if (word == u"italic" || word == u"oblique") {
italic = true;
words.removeLast();
} else if (weights.contains(word)) {
weight = weights.value(word);
words.removeLast();
} else {
break;
}
}
into.insert(QStringLiteral("family"), words.isEmpty() ? QStringLiteral("Sans") : words.join(u' '));
into.insert(QStringLiteral("weight"), weight);
into.insert(QStringLiteral("italic"), italic);
}
// Pango markup as Qt's StyledText: span becomes font, b and i, the rest of
// Pango's tags go and their text stays.
QString styled(const QString &markup)
{
static const QRegularExpression tag(QStringLiteral("<(/?)([a-zA-Z]+)([^>]*)>"));
static const QRegularExpression attribute(QStringLiteral("([a-z_]+)\\s*=\\s*[\"']([^\"']*)[\"']"));
QString out;
QStringList closers;
qsizetype last = 0;
auto it = tag.globalMatch(markup);
while (it.hasNext()) {
const auto m = it.next();
out += markup.mid(last, m.capturedStart() - last);
last = m.capturedEnd();
const bool closing = !m.captured(1).isEmpty();
const QString name = m.captured(2).toLower();
if (name == u"span") {
if (closing) {
out += closers.isEmpty() ? QString() : closers.takeLast();
continue;
}
QString open;
QString close;
QString fontAttrs;
auto attrs = attribute.globalMatch(m.captured(3));
while (attrs.hasNext()) {
const auto a = attrs.next();
const QString key = a.captured(1);
QString value = a.captured(2);
if (key == u"foreground" || key == u"fgcolor" || key == u"color") {
// Pango's #RRGGBBAA is Qt's #AARRGGBB.
if (value.startsWith(u'#') && value.size() == 9) {
value = u'#' + value.mid(7, 2) + value.mid(1, 6);
}
fontAttrs += QStringLiteral(" color=\"%1\"").arg(value);
} else if (key == u"font_family" || key == u"face") {
fontAttrs += QStringLiteral(" face=\"%1\"").arg(value);
} else if ((key == u"weight" || key == u"font_weight") && (value.contains(u"bold") || value.contains(u"heavy") || value.toInt() >= 600)) {
open += QStringLiteral("<b>");
close.prepend(QStringLiteral("</b>"));
} else if ((key == u"style" || key == u"font_style") && value == u"italic") {
open += QStringLiteral("<i>");
close.prepend(QStringLiteral("</i>"));
}
}
if (!fontAttrs.isEmpty()) {
open.prepend(QStringLiteral("<font%1>").arg(fontAttrs));
close += QStringLiteral("</font>");
}
out += open;
closers.append(close);
} else if (name == u"b" || name == u"i" || name == u"u") {
out += m.captured(0);
}
}
out += markup.mid(last);
out.replace(u'\n', QStringLiteral("<br>"));
return out;
}
// What a command prints, or nothing when it takes too long.
QString run(const QString &command, QElapsedTimer &budget)
{
// A config full of slow commands (weather, music) must not keep the
// window from opening.
if (budget.elapsed() > 2000) {
return {};
}
QProcess process;
process.start(QStringLiteral("/bin/sh"), {QStringLiteral("-c"), command});
if (!process.waitForFinished(700)) {
process.kill();
process.waitForFinished(100);
return {};
}
return QString::fromUtf8(process.readAllStandardOutput());
}
// A label's text as hyprlock would show it right after locking.
QString labelText(QString text, bool trim, QElapsedTimer &budget)
{
const passwd *pw = getpwuid(getuid());
text.replace(QStringLiteral("$DESC"), pw ? QString::fromLocal8Bit(pw->pw_gecos) : QString());
text.replace(QStringLiteral("$USER"), pw ? QString::fromLocal8Bit(pw->pw_name) : QString());
text.replace(QStringLiteral("<br/>"), QStringLiteral("\n"));
const QTime now = QTime::currentTime();
text.replace(QStringLiteral("$TIME12"), now.toString(QStringLiteral("hh:mm AP")));
text.replace(QStringLiteral("$TIME"), now.toString(QStringLiteral("HH:mm")));
static const QRegularExpression layoutVar(QStringLiteral("\\$LAYOUT(\\[([^\\]]*)\\])?"));
auto layouts = layoutVar.globalMatch(text);
while (layouts.hasNext()) {
const auto m = layouts.next();
const QString first = m.captured(2).section(u',', 0, 0).trimmed();
text.replace(m.captured(0), first.isEmpty() ? QStringLiteral("en") : first);
}
// What only a lock screen that runs knows: nothing has failed yet.
static const QRegularExpression runtime(QStringLiteral("\\$(ATTEMPTS(\\[[^\\]]*\\])?|PAMFAIL|FPRINTFAIL|FPRINTPROMPT|FAIL)"));
text.remove(runtime);
text.replace(QStringLiteral("$PAMPROMPT"), QStringLiteral("Password:"));
if (text.startsWith(u"cmd[") && text.contains(u']')) {
const QString command = text.mid(text.indexOf(u']') + 1);
// face-unlock's own line, as it reads during a scan.
text = command.contains(u"face-unlock/lock-text") ? i18n("Looking for your face…").toHtmlEscaped() : run(command, budget);
}
return trim ? text.trimmed() : text;
}
void place(const Section &s, const QString &position, const QSizeF &screen, QVariantMap &into)
{
const QPointF pos = layout(s.values.value(QStringLiteral("position"), position), screen);
into.insert(QStringLiteral("x"), pos.x());
into.insert(QStringLiteral("y"), pos.y());
into.insert(QStringLiteral("halign"), s.values.value(QStringLiteral("halign"), QStringLiteral("center")));
into.insert(QStringLiteral("valign"), s.values.value(QStringLiteral("valign"), QStringLiteral("center")));
into.insert(QStringLiteral("rotate"), s.values.value(QStringLiteral("rotate"), QStringLiteral("0")).toDouble());
}
QColor colorOr(const QString &value, const QColor &fallback)
{
const QColor c = LockPreview::color(value);
return c.isValid() ? c : fallback;
}
// swaylock's colours: RRGGBB or RRGGBBAA, without a #.
QColor hexColor(const QString &value, const QColor &fallback)
{
QString v = value.trimmed();
if (v.startsWith(u'#')) {
v = v.mid(1);
}
bool ok = false;
const uint n = v.toUInt(&ok, 16);
if (!ok || (v.size() != 6 && v.size() != 8)) {
return fallback;
}
return v.size() == 6 ? QColor((n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff) : QColor((n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff);
}
QVariantMap wallpaperOnly(const QUrl &wallpaper)
{
return {{QStringLiteral("type"), QStringLiteral("background")},
{QStringLiteral("color"), QColor(0x11, 0x11, 0x11)},
{QStringLiteral("source"), wallpaper},
{QStringLiteral("fill"), QStringLiteral("fill")},
{QStringLiteral("blur"), 0.0},
{QStringLiteral("dim"), 0.0}};
}
} // namespace
QColor LockPreview::color(const QString &value)
{
const QString v = value.trimmed();
static const QRegularExpression call(QStringLiteral("^(rgba?)\\((.*)\\)$"));
const auto m = call.match(v);
if (m.hasMatch()) {
const QString inner = m.captured(2).trimmed();
const QStringList parts = inner.split(u',');
if (parts.size() >= 3) {
QColor c(parts.at(0).trimmed().toInt(), parts.at(1).trimmed().toInt(), parts.at(2).trimmed().toInt());
if (parts.size() >= 4) {
c.setAlphaF(std::clamp(parts.at(3).trimmed().toDouble(), 0.0, 1.0));
}
return c;
}
return hexColor(inner, {});
}
bool ok = false;
const qulonglong n = v.startsWith(u"0x", Qt::CaseInsensitive) ? v.mid(2).toULongLong(&ok, 16) : v.toULongLong(&ok, 10);
if (!ok) {
return {};
}
return QColor(int((n >> 16) & 0xff), int((n >> 8) & 0xff), int(n & 0xff), int((n >> 24) & 0xff));
}
QString LockPreview::locker()
{
static const QStringList names{QStringLiteral("hyprlock"), QStringLiteral("swaylock"), QStringLiteral("gtklock"), QStringLiteral("waylock")};
// Where a lock screen is started from: the idle daemons first, then the
// keys. The first one named there, outside comments.
static const QStringList places{QStringLiteral("hypr/hypridle.conf"), QStringLiteral("swayidle/config"), QStringLiteral("niri/config.kdl"),
QStringLiteral("hypr/hyprland.lua"), QStringLiteral("hypr/hyprland.conf")};
for (const QString &place : places) {
for (const QString &line : readFile(configHome() + u'/' + place).split(u'\n')) {
const QString code = line.trimmed();
if (code.startsWith(u'#') || code.startsWith(u"//") || code.startsWith(u"--")) {
continue;
}
qsizetype first = -1;
QString found;
for (const QString &name : names) {
const qsizetype at = code.indexOf(name);
if (at >= 0 && (first < 0 || at < first)) {
first = at;
found = name;
}
}
if (!found.isEmpty()) {
return found;
}
}
}
// Started some other way (a script of its own): one that is set up.
if (!QStandardPaths::findExecutable(QStringLiteral("hyprlock")).isEmpty() && !findConfig(QStringLiteral("hypr/hyprlock.conf")).isEmpty()) {
return QStringLiteral("hyprlock");
}
for (const QString &name : names) {
if (!QStandardPaths::findExecutable(name).isEmpty()) {
return name;
}
}
return {};
}
QVariantList LockPreview::hyprlock(const QString &configPath, const QSizeF &screen, const QString &output, const QUrl &wallpaper)
{
Hyprlang config;
config.parse(configPath);
bool trim = true;
for (const Section &s : std::as_const(config.sections)) {
if (s.name == u"general" && s.values.contains(QStringLiteral("text_trim"))) {
trim = truthy(s.values.value(QStringLiteral("text_trim")));
}
}
QElapsedTimer budget;
budget.start();
QList<QPair<int, QVariantMap>> placed;
bool ourLine = false;
bool background = false;
for (const Section &s : std::as_const(config.sections)) {
const auto value = [&s](const char *key, const char *fallback) {
return s.values.value(QString::fromLatin1(key), QString::fromLatin1(fallback));
};
const QString monitor = value("monitor", "");
if (!monitor.isEmpty() && monitor != output && !monitor.startsWith(u"desc:")) {
continue;
}
QVariantMap w;
int z = value("zindex", "0").toInt();
if (s.name == u"background") {
background = true;
z = value("zindex", "-1").toInt();
const QString path = value("path", "");
const int passes = value("blur_passes", "0").toInt();
w = wallpaperOnly(path == u"screenshot" ? wallpaper : path.isEmpty() ? QUrl() : QUrl::fromLocalFile(expandHome(path)));
w.insert(QStringLiteral("color"), colorOr(value("color", ""), QColor(0x11, 0x11, 0x11)));
w.insert(QStringLiteral("blur"), passes > 0 ? std::min(1.0, passes * value("blur_size", "8").toDouble() / 24.0) : 0.0);
// hyprlock's blur also darkens, by its brightness.
w.insert(QStringLiteral("dim"), passes > 0 ? std::clamp(1.0 - value("brightness", "0.8172").toDouble(), 0.0, 1.0) : 0.0);
} else if (s.name == u"label") {
const QString text = value("text", "Sample Text");
ourLine = ourLine || text.contains(u"face-unlock/lock-text");
w.insert(QStringLiteral("type"), QStringLiteral("label"));
w.insert(QStringLiteral("text"), styled(labelText(text, trim, budget)));
font(value("font_family", "Sans"), w);
w.insert(QStringLiteral("size"), value("font_size", "16").toDouble());
w.insert(QStringLiteral("color"), colorOr(value("color", ""), Qt::white));
w.insert(QStringLiteral("align"), value("text_align", "center"));
w.insert(QStringLiteral("shadow"), value("shadow_passes", "0").toInt() > 0);
w.insert(QStringLiteral("shadowSize"), value("shadow_size", "3").toDouble());
w.insert(QStringLiteral("shadowColor"), colorOr(value("shadow_color", ""), Qt::black));
place(s, QStringLiteral("0,0"), screen, w);
} else if (s.name == u"input-field") {
const QPointF size = layout(value("size", "400,90"), screen);
w.insert(QStringLiteral("type"), QStringLiteral("input"));
w.insert(QStringLiteral("width"), size.x());
w.insert(QStringLiteral("height"), size.y());
w.insert(QStringLiteral("thickness"), value("outline_thickness", "4").toDouble());
w.insert(QStringLiteral("outer"), gradient(value("outer_color", "0xFF111111")));
w.insert(QStringLiteral("inner"), colorOr(value("inner_color", ""), QColor(0xdd, 0xdd, 0xdd)));
w.insert(QStringLiteral("fontColor"), colorOr(value("font_color", ""), Qt::black));
font(value("font_family", "Sans"), w);
w.insert(QStringLiteral("placeholder"), styled(labelText(value("placeholder_text", "<i>Input Password</i>"), trim, budget)));
w.insert(QStringLiteral("rounding"), value("rounding", "-1").toDouble());
place(s, QStringLiteral("0,0"), screen, w);
} else if (s.name == u"shape") {
const QPointF size = layout(value("size", "100,100"), screen);
w.insert(QStringLiteral("type"), QStringLiteral("shape"));
w.insert(QStringLiteral("width"), size.x());
w.insert(QStringLiteral("height"), size.y());
w.insert(QStringLiteral("color"), colorOr(value("color", ""), QColor(0x11, 0x11, 0x11)));
w.insert(QStringLiteral("rounding"), value("rounding", "0").toDouble());
w.insert(QStringLiteral("borderSize"), value("border_size", "0").toDouble());
w.insert(QStringLiteral("border"), gradient(value("border_color", "0xFF00CFE6")));
place(s, QStringLiteral("0,0"), screen, w);
} else if (s.name == u"image") {
const QString path = value("path", "");
if (path.isEmpty()) {
continue;
}
w.insert(QStringLiteral("type"), QStringLiteral("image"));
w.insert(QStringLiteral("source"), QUrl::fromLocalFile(expandHome(path)));
w.insert(QStringLiteral("size"), value("size", "150").toDouble());
w.insert(QStringLiteral("rounding"), value("rounding", "-1").toDouble());
w.insert(QStringLiteral("borderSize"), value("border_size", "4").toDouble());
w.insert(QStringLiteral("border"), gradient(value("border_color", "0xFFDDDDDD")));
place(s, QStringLiteral("0,0"), screen, w);
} else {
continue;
}
placed.append({z, w});
}
if (!background) {
QVariantMap w = wallpaperOnly({});
placed.append({-1, w});
}
// face-unlock's line, as the menu puts it in (fu_hyprlock_text_on),
// for those who have not picked it yet.
if (!ourLine) {
QVariantMap w{{QStringLiteral("type"), QStringLiteral("label")},
{QStringLiteral("text"), i18n("Looking for your face…").toHtmlEscaped()},
{QStringLiteral("size"), 20.0},
{QStringLiteral("color"), QColor(255, 255, 255, 242)},
{QStringLiteral("align"), QStringLiteral("center")},
{QStringLiteral("shadow"), true},
{QStringLiteral("shadowSize"), 3.0},
{QStringLiteral("shadowColor"), QColor(Qt::black)},
{QStringLiteral("x"), 0.0},
{QStringLiteral("y"), -48.0},
{QStringLiteral("halign"), QStringLiteral("center")},
{QStringLiteral("valign"), QStringLiteral("top")},
{QStringLiteral("rotate"), 0.0}};
font(QStringLiteral("Sans"), w);
placed.append({10, w});
}
std::stable_sort(placed.begin(), placed.end(), [](const auto &a, const auto &b) {
return a.first < b.first;
});
QVariantList widgets;
for (const auto &p : std::as_const(placed)) {
widgets.append(p.second);
}
return widgets;
}
QVariantList LockPreview::swaylock(const QString &configPath, const QUrl &wallpaper)
{
// One option per line, as on the command line without the dashes.
QHash<QString, QString> o;
for (const QString &raw : readFile(configPath).split(u'\n')) {
const QString line = raw.trimmed();
if (line.isEmpty() || line.startsWith(u'#')) {
continue;
}
o.insert(line.section(u'=', 0, 0).trimmed(), line.contains(u'=') ? line.section(u'=', 1).trimmed() : QString());
}
// An image may name its output first: [[<output>]:]<path>.
QString image = o.value(QStringLiteral("image"));
if (image.contains(u':') && !image.startsWith(u'/') && !image.startsWith(u'~')) {
image = image.section(u':', 1);
}
QVariantMap bg = wallpaperOnly(o.contains(QStringLiteral("screenshots")) ? wallpaper
: image.isEmpty() ? QUrl()
: QUrl::fromLocalFile(expandHome(image)));
bg.insert(QStringLiteral("color"), hexColor(o.value(QStringLiteral("color")), Qt::white));
bg.insert(QStringLiteral("fill"), o.value(QStringLiteral("scaling"), QStringLiteral("fill")));
// swaylock-effects: effect-blur=<radius>x<times>.
const QString blur = o.value(QStringLiteral("effect-blur"));
if (!blur.isEmpty()) {
bg.insert(QStringLiteral("blur"), std::min(1.0, blur.section(u'x', 0, 0).toDouble() * blur.section(u'x', 1, 1).toDouble() / 24.0));
}
QVariantList widgets{bg};
// The ring shows only while typing, unless it is asked to stay.
const bool clock = o.contains(QStringLiteral("clock"));
if (o.contains(QStringLiteral("indicator")) || o.contains(QStringLiteral("indicator-idle-visible")) || clock) {
const QDateTime now = QDateTime::currentDateTime();
widgets.append(QVariantMap{
{QStringLiteral("type"), QStringLiteral("ring")},
{QStringLiteral("radius"), o.value(QStringLiteral("indicator-radius"), QStringLiteral("50")).toDouble()},
{QStringLiteral("thickness"), o.value(QStringLiteral("indicator-thickness"), QStringLiteral("10")).toDouble()},
{QStringLiteral("inside"), hexColor(o.value(QStringLiteral("inside-color")), QColor(0, 0, 0, 0xc0))},
{QStringLiteral("ring"), hexColor(o.value(QStringLiteral("ring-color")), QColor(0x33, 0x7d, 0x00))},
{QStringLiteral("textColor"), hexColor(o.value(QStringLiteral("text-color")), QColor(0xe5, 0xa4, 0x45))},
{QStringLiteral("text"), clock ? now.toString(QStringLiteral("HH:mm")) + u'\n' + now.toString(QStringLiteral("yyyy-MM-dd")) : QString()},
});
}
return widgets;
}
QVariantList LockPreview::widgets(const QString &locker, const QSizeF &screen, const QString &output, const QUrl &wallpaper)
{
if (locker == u"hyprlock") {
const QString path = findConfig(QStringLiteral("hypr/hyprlock.conf"));
if (!path.isEmpty()) {
return hyprlock(path, screen, output, wallpaper);
}
} else if (locker == u"swaylock") {
QString path = findConfig(QStringLiteral("swaylock/config"));
if (path.isEmpty() && QFileInfo::exists(QDir::homePath() + QStringLiteral("/.swaylock/config"))) {
path = QDir::homePath() + QStringLiteral("/.swaylock/config");
}
return swaylock(path, wallpaper);
}
// Something this does not know how to draw: the desktop's picture.
return {wallpaperOnly(wallpaper)};
}
+37
View File
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The user's own lock screen, rebuilt for its picture in the window that asks
// which lock screen to use (LockChoice.qml): what hyprlock draws from its
// config, with face-unlock's line at the top, or swaylock's background and
// ring. A screenshot would take locking the screen. LockPreview.qml draws
// what this returns.
//
// hyprlock places a widget from the corner or middle its halign and valign
// name, with y upwards, and takes font sizes as points. Colours and sizes
// here are hyprlang's: rgba(r, g, b, a), rgba(RRGGBBAA), 0xAARRGGBB, and
// "x, y" in pixels or percent of the screen.
#pragma once
#include <QColor>
#include <QSizeF>
#include <QString>
#include <QUrl>
#include <QVariantList>
namespace LockPreview
{
// The lock screen the user starts: the one their idle daemon or key runs,
// else one that is installed. hyprlock, swaylock, another name, or empty.
QString locker();
// That lock screen's widgets, bottom first, as maps for LockPreview.qml.
// output names the screen, for widgets meant for one monitor; wallpaper is
// the desktop's picture, for a background that is a screenshot.
QVariantList widgets(const QString &locker, const QSizeF &screen, const QString &output, const QUrl &wallpaper);
// The pieces, apart for the tests.
QVariantList hyprlock(const QString &configPath, const QSizeF &screen, const QString &output, const QUrl &wallpaper);
QVariantList swaylock(const QString &configPath, const QUrl &wallpaper);
QColor color(const QString &value);
}
+86
View File
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "locktext.h"
#include "bubblecontroller.h"
#include "lockers.h"
#include "userconfig.h"
#include <KLocalizedString>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QStandardPaths>
#include <QTimer>
#include <csignal>
LockText::LockText(BubbleController *bubble, UserConfig *config, QObject *parent)
: QObject(parent)
, m_bubble(bubble)
, m_config(config)
{
connect(m_bubble, &BubbleController::phaseChanged, this, &LockText::update);
connect(m_bubble, &BubbleController::messageChanged, this, &LockText::update);
connect(m_config, &UserConfig::changed, this, &LockText::update);
// Nothing left over from an agent before this one.
write({});
}
LockText::~LockText()
{
QFile::remove(path());
}
QString LockText::path()
{
return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + QStringLiteral("/face-unlock/lock-text");
}
QString LockText::text(const QString &phase, const QString &message)
{
if (phase == u"scanning") {
return message.isEmpty() ? i18n("Looking for your face…") : message;
}
if (phase == u"success") {
return i18n("Face recognized");
}
// failure and lockout say why; hidden says nothing.
return phase == u"hidden" ? QString() : message;
}
void LockText::update()
{
const bool on = m_config->lockScreenStyle() == u"yours";
const QString now = on ? text(m_bubble->phase(), m_bubble->message()) : QString();
if (now != m_text) {
write(now);
// Left alone by those who did not pick it: their hyprlock has no
// label to read it.
m_tries = 0;
refresh();
}
}
void LockText::refresh()
{
// A hyprlock that is only starting reads the file once it is up, but
// may have read it just before this changed: asked again then.
if (Lockers::signal(SIGUSR2, "hyprlock") > 0 && ++m_tries < 20) {
QTimer::singleShot(250, this, &LockText::refresh);
}
}
void LockText::write(const QString &text)
{
m_text = text;
QDir().mkpath(QFileInfo(path()).absolutePath());
QSaveFile file(path());
// hyprlock reads labels as Pango markup.
if (file.open(QIODevice::WriteOnly)) {
file.write(text.toHtmlEscaped().toUtf8());
file.commit();
}
}
+37
View File
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The bubble as a line of text, for a lock screen that covers it: hyprlock.
// When the user keeps their own lock screen (LockScreenStyle=yours), the
// menu puts a label into hyprlock's config that shows the file at path()
// (see src/lib/system.sh). This writes what the bubble shows into it, and
// SIGUSR2 makes hyprlock read it again.
#pragma once
#include <QObject>
#include <QString>
class BubbleController;
class UserConfig;
class LockText : public QObject
{
Q_OBJECT
public:
LockText(BubbleController *bubble, UserConfig *config, QObject *parent = nullptr);
~LockText() override;
static QString path();
// The line for a phase and message of the bubble.
static QString text(const QString &phase, const QString &message);
private:
void update();
void refresh();
void write(const QString &text);
BubbleController *m_bubble;
UserConfig *m_config;
QString m_text;
int m_tries = 0;
};
+70 -1
View File
@@ -10,6 +10,9 @@
// Niri), else through logind with the desktop's
// --demo play the bubble's animations once, for trying out a
// style (--bubble-style minimal) and for screenshots
// --choose-lock-screen
// open the window that asks which lock screen to use
// where it is a program of its own, and print the answer
#include "agentsocket.h"
#include "bubblecontroller.h"
@@ -17,9 +20,12 @@
#include "bubblewindow.h"
#include "enrollcontroller.h"
#include "lockcontroller.h"
#include "lockpreview.h"
#include "lockscreencontroller.h"
#include "locktext.h"
#include "sessionlock.h"
#include "userconfig.h"
#include "wallpaper.h"
#include "wayland.h"
#include "buildconfig.h"
@@ -34,8 +40,11 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlEngine>
#include <QScreen>
#include <QTimer>
#include <QWindow>
#include <cstdio>
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
@@ -66,6 +75,56 @@ int runEnroll(QGuiApplication &app, const QString &name)
return controller.state() == u"done" ? 0 : code;
}
// The window of --choose-lock-screen. Prints "own" or "yours" for the menu,
// which carries it out.
int runChooseLockScreen(QGuiApplication &app)
{
app.setQuitOnLastWindowClosed(true);
UserConfig config;
// Both choices show this screen as it would look: face-unlock's lock
// screen as it is, with a scan going on, and the user's own rebuilt.
const QScreen *screen = QGuiApplication::primaryScreen();
const QString output = screen ? screen->name() : QString();
const QSize size = screen ? screen->size() : QSize(1920, 1080);
const QUrl desktop = Wallpaper::pick(Wallpaper::forLock({}), output);
const QUrl ownWallpaper = config.lockWallpaper().isEmpty() ? desktop : Wallpaper::pick(Wallpaper::forLock(config.lockWallpaper()), output);
const QString locker = LockPreview::locker();
LockScreenController lock;
lock.setBlur(config.lockBlur());
BubbleController bubble(&config);
bubble.scanStarted();
// A scan that goes on: the bubble closes by itself after a while.
QTimer rescan;
QObject::connect(&rescan, &QTimer::timeout, &bubble, &BubbleController::scanStarted);
rescan.start(20000);
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
engine.setInitialProperties({{QStringLiteral("current"), config.lockScreenStyle()},
{QStringLiteral("locker"), locker},
{QStringLiteral("widgets"), LockPreview::widgets(locker, size, output, desktop)},
{QStringLiteral("screenSize"), size},
{QStringLiteral("ownWallpaper"), ownWallpaper},
{QStringLiteral("lock"), QVariant::fromValue(&lock)},
{QStringLiteral("bubble"), QVariant::fromValue(&bubble)}});
engine.loadFromModule(QStringLiteral("FaceUnlock"), QStringLiteral("LockChoice"));
auto *window = engine.rootObjects().isEmpty() ? nullptr : qobject_cast<QWindow *>(engine.rootObjects().constFirst());
if (!window) {
return 2;
}
// Shown only now, at the size its texts need (see LockChoice.qml).
window->show();
app.exec();
const QString answer = window->property("answer").toString();
if (answer.isEmpty()) {
return 1;
}
std::printf("%s\n", qPrintable(answer));
return 0;
}
// Where --lock tells the command that ran it that the screen is locked, when
// it forked to stay behind as the lock screen. -1: it did not.
int readyFd = -1;
@@ -191,9 +250,14 @@ int main(int argc, char **argv)
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, lockOpt, demoOpt, styleOpt});
const QCommandLineOption chooseOpt(QStringLiteral("choose-lock-screen"), i18n("Ask which lock screen to use, and print the answer."));
parser.addOptions({enrollOpt, nameOpt, lockOpt, demoOpt, styleOpt, chooseOpt});
parser.process(app);
if (parser.isSet(chooseOpt)) {
return runChooseLockScreen(app);
}
if (parser.isSet(enrollOpt)) {
QString name = parser.value(nameOpt);
if (name.isEmpty()) {
@@ -264,6 +328,11 @@ int main(int argc, char **argv)
QObject::connect(lock, &SessionLock::confirmed, &socket, &AgentSocket::confirmLock);
}
LockController lock(&bubble, &config, sessionLock.get(), &screen);
// hyprlock covers the bubble: it can show it as a line of text instead.
std::unique_ptr<LockText> text;
if (sessionLock && !lockNow) {
text = std::make_unique<LockText>(&bubble, &config);
}
if (lockNow) {
// No agent was running, so this one is only here for the lock. It
+264
View File
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Where the lock screen is a program of the user's (Hyprland, Niri): lock
// with face-unlock's own, with the bubble, or keep that program, which then
// shows a line of text (hyprlock). Each choice shows the user's screen as it
// would look: face-unlock's lock screen live, and their own rebuilt from its
// config (see LockPreview). The pick goes back to the menu, which carries
// it out.
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import FaceUnlock
Window {
id: window
// "own", "yours", or empty when there is none yet.
required property string current
// The user's lock screen (see LockPreview::locker) and what it shows.
required property string locker
required property var widgets
// The screen the pictures show, and the parts of face-unlock's own.
required property size screenSize
required property url ownWallpaper
required property var lock
required property var bubble
// What was picked, empty when the window was closed.
property string answer: ""
property string selected: current
function pick() {
if (selected.length > 0) {
answer = selected
close()
}
}
// One fixed size, so tiling compositors float it (see Enroll). The
// height follows the texts, whose length depends on the language. It
// is taken from them rather than from the Row and Column, which only lay
// out once the window shows, and the agent shows it after that.
readonly property int fitHeight: Math.ceil(36 + heading.height + 10 + intro.height + 28 + Math.max(own.needed, yours.needed) + 94)
width: 800
height: fitHeight
minimumWidth: 800
maximumWidth: 800
minimumHeight: fitHeight
maximumHeight: fitHeight
visible: false
color: Theme.panel
title: i18n("Choose your lock screen")
Component {
id: ownScene
LockScreen {
lock: window.lock
bubble: window.bubble
primary: true
wallpaper: window.ownWallpaper
interactive: false
}
}
Component {
id: yoursScene
LockPreview {
widgets: window.widgets
}
}
// One of the two: the screen, a radio button with the name, what it means.
component Choice: Rectangle {
id: card
required property string style
required property Component scene
required property string name
required property string about
readonly property bool chosen: window.selected === style
// The height its text needs; both take the larger one.
readonly property real needed: body.y + body.height + 20
width: 364
radius: 18
color: area.containsMouse && !chosen ? Qt.lighter(Theme.surface, 1.25) : Theme.surface
border.width: 2
border.color: chosen ? Theme.accent : "transparent"
Behavior on border.color { ColorAnimation { duration: 150 } }
Behavior on color { ColorAnimation { duration: 120 } }
// The whole screen, made small: filling the picture, cut at the
// sides or at the top and bottom.
Item {
id: picture
x: 14
y: 14
width: parent.width - 28
height: Math.round(width * 10 / 16)
clip: true
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: mask
}
Loader {
sourceComponent: card.scene
width: window.screenSize.width
height: window.screenSize.height
transformOrigin: Item.TopLeft
scale: Math.max(picture.width / width, picture.height / height)
x: (picture.width - width * scale) / 2
y: (picture.height - height * scale) / 2
}
}
Rectangle {
id: mask
width: picture.width
height: picture.height
radius: 10
visible: false
layer.enabled: true
}
// A radio button: a ring, filled when picked.
Rectangle {
id: radio
x: 18
y: title.y + 3
width: 18
height: 18
radius: 9
color: "transparent"
border.width: 2
border.color: card.chosen ? Theme.accent : Theme.textSecondary
Rectangle {
anchors.centerIn: parent
width: 8
height: 8
radius: 4
color: Theme.accent
visible: card.chosen
}
}
Text {
id: title
anchors.top: picture.bottom
anchors.topMargin: 16
x: radio.x + radio.width + 10
width: parent.width - x - 18
wrapMode: Text.WordWrap
color: Theme.textPrimary
font.pixelSize: 16
font.weight: Font.DemiBold
text: card.name
}
Text {
id: body
anchors.top: title.bottom
anchors.topMargin: 8
x: 18
width: parent.width - 36
wrapMode: Text.WordWrap
color: Theme.textDetail
font.pixelSize: 13
lineHeight: 1.15
text: card.about
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: window.selected = card.style
onDoubleClicked: {
window.selected = card.style
window.pick()
}
}
}
Column {
id: head
anchors.top: parent.top
anchors.topMargin: 36
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 96
spacing: 10
Text {
id: heading
width: parent.width
horizontalAlignment: Text.AlignHCenter
color: Theme.textPrimary
font.pixelSize: 26
font.weight: Font.Bold
text: i18n("Your lock screen")
}
Text {
id: intro
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: Theme.textSecondary
font.pixelSize: 13
font.weight: Font.Medium
lineHeight: 1.15
text: i18n("Your desktop leaves the lock screen to a program of your choice. Pick how face unlock shows up there. You can change it later under Settings.")
}
}
Row {
id: cards
anchors.top: head.bottom
anchors.topMargin: 28
anchors.horizontalCenter: parent.horizontalCenter
spacing: 24
focus: true
Keys.onLeftPressed: window.selected = "own"
Keys.onRightPressed: window.selected = "yours"
Keys.onReturnPressed: window.pick()
Keys.onEnterPressed: window.pick()
Keys.onEscapePressed: window.close()
Choice {
id: own
height: Math.max(own.needed, yours.needed)
style: "own"
scene: ownScene
name: i18n("face-unlock's lock screen")
about: i18n("The bubble at the top, the time and your wallpaper. Your shortcut and idle lock then run face-unlock lock.")
}
Choice {
id: yours
height: own.height
style: "yours"
scene: yoursScene
name: i18n("Keep your lock screen")
about: window.locker === "hyprlock"
? i18n("It stays as it is. hyprlock shows a line of text at the top instead of the bubble. Enter on the empty password field scans.")
: i18n("It stays as it is, without the bubble. Enter on the empty password field scans.")
}
}
Row {
anchors.bottom: parent.bottom
anchors.bottomMargin: 32
anchors.horizontalCenter: parent.horizontalCenter
spacing: 12
PillButton {
primary: false
text: i18n("Cancel")
onClicked: window.close()
}
PillButton {
enabled: window.selected.length > 0
text: i18n("Continue")
onClicked: window.pick()
}
}
}
+249
View File
@@ -0,0 +1,249 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The user's own lock screen, drawn from what LockPreview (C++) read out of
// its config, at the size of the screen. Whoever shows it scales it down.
// Placement as in hyprlock: from the side or middle halign and valign name,
// x to the right and y upwards. One Repeater per kind of widget, stacked in
// the order hyprlock draws them.
import QtQuick
import QtQuick.Effects
Item {
id: root
required property var widgets
readonly property var numbered: widgets.map((w, i) => Object.assign({order: i}, w))
function only(type) {
return numbered.filter(w => w.type === type)
}
function alignX(halign, size, offset) {
return halign === "center" ? (width - size) / 2 + offset
: halign === "right" ? width - size + offset
: offset
}
function alignY(valign, size, offset) {
const up = valign === "center" ? (height - size) / 2 + offset
: valign === "top" ? height - size + offset
: offset
return height - up - size
}
clip: true
Repeater {
model: root.only("background")
Item {
id: back
required property var modelData
readonly property var w: modelData
z: w.order
width: root.width
height: root.height
Rectangle {
anchors.fill: parent
color: back.w.color
}
Image {
// Past the edges when blurred: the blur would pull in the
// nothing beyond them and darken the rim.
anchors.fill: parent
anchors.margins: back.w.blur > 0 ? -Math.round(64 * back.w.blur) : 0
visible: back.w.fill !== "solid_color"
source: back.w.source
asynchronous: true
sourceSize: Qt.size(root.width, root.height)
fillMode: back.w.fill === "fit" ? Image.PreserveAspectFit
: back.w.fill === "stretch" ? Image.Stretch
: back.w.fill === "center" ? Image.Pad
: back.w.fill === "tile" ? Image.Tile
: Image.PreserveAspectCrop
layer.enabled: back.w.blur > 0
layer.effect: MultiEffect {
blurEnabled: true
blur: 1
blurMax: Math.round(64 * back.w.blur)
}
}
Rectangle {
anchors.fill: parent
color: "black"
opacity: back.w.dim
}
}
}
Repeater {
model: root.only("label")
Text {
id: line
required property var modelData
readonly property var w: modelData
z: w.order
x: root.alignX(w.halign, width, w.x)
y: root.alignY(w.valign, height, w.y)
rotation: w.rotate
text: w.text
textFormat: Text.StyledText
color: w.color
font.family: w.family
font.weight: w.weight
font.italic: w.italic
font.pointSize: w.size
horizontalAlignment: w.align === "left" ? Text.AlignLeft : w.align === "right" ? Text.AlignRight : Text.AlignHCenter
layer.enabled: w.shadow
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: line.w.shadowColor
shadowBlur: Math.min(1, line.w.shadowSize / 8)
shadowHorizontalOffset: 0
shadowVerticalOffset: 0
}
}
}
Repeater {
model: root.only("input")
// The outline is the outer colour around the inner one. A gradient
// outline needs an opaque inside, which leaves it showing only at
// the edge.
Rectangle {
id: field
required property var modelData
readonly property var w: modelData
readonly property var outer: w.outer.colors
readonly property bool blend: outer.length > 1 && w.inner.a >= 0.99
z: w.order
x: root.alignX(w.halign, width, w.x)
y: root.alignY(w.valign, height, w.y)
rotation: w.rotate
width: w.width
height: w.height
radius: w.rounding < 0 ? height / 2 : Math.min(w.rounding, height / 2)
color: blend ? "transparent" : w.inner
border.width: blend ? 0 : w.thickness
border.color: outer.length > 0 ? outer[0] : "transparent"
gradient: blend ? outline : null
Gradient {
id: outline
orientation: Math.abs(Math.cos(field.w.outer.angle * Math.PI / 180)) >= Math.abs(Math.sin(field.w.outer.angle * Math.PI / 180))
? Gradient.Horizontal : Gradient.Vertical
GradientStop { position: 0; color: field.outer.length > 0 ? field.outer[0] : "transparent" }
GradientStop { position: 1; color: field.outer.length > 0 ? field.outer[field.outer.length - 1] : "transparent" }
}
Rectangle {
visible: field.blend
anchors.fill: parent
anchors.margins: field.w.thickness
radius: Math.max(0, field.radius - field.w.thickness)
color: field.w.inner
}
Text {
anchors.centerIn: parent
text: field.w.placeholder
textFormat: Text.StyledText
color: field.w.fontColor
font.family: field.w.family
font.weight: field.w.weight
font.italic: field.w.italic
font.pointSize: Math.max(1, field.height / 4)
}
}
}
Repeater {
model: root.only("shape")
Rectangle {
required property var modelData
readonly property var w: modelData
z: w.order
x: root.alignX(w.halign, width, w.x)
y: root.alignY(w.valign, height, w.y)
rotation: w.rotate
width: w.width
height: w.height
radius: w.rounding < 0 ? Math.min(width, height) / 2 : Math.min(w.rounding, Math.min(width, height) / 2)
color: w.color
border.width: w.borderSize
border.color: w.border.colors.length > 0 ? w.border.colors[0] : "transparent"
}
}
Repeater {
model: root.only("image")
Item {
id: frame
required property var modelData
readonly property var w: modelData
readonly property real round: w.rounding < 0 ? width / 2 : Math.min(w.rounding, width / 2)
z: w.order
x: root.alignX(w.halign, width, w.x)
y: root.alignY(w.valign, height, w.y)
rotation: w.rotate
width: w.size + 2 * w.borderSize
height: width
Rectangle {
anchors.fill: parent
radius: frame.round
color: frame.w.border.colors.length > 0 ? frame.w.border.colors[0] : "transparent"
}
Image {
id: photo
anchors.fill: parent
anchors.margins: frame.w.borderSize
source: frame.w.source
fillMode: Image.PreserveAspectCrop
asynchronous: true
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: photoMask
}
}
Rectangle {
id: photoMask
width: photo.width
height: photo.height
radius: Math.max(0, frame.round - frame.w.borderSize)
visible: false
layer.enabled: true
}
}
}
Repeater {
model: root.only("ring")
// swaylock's indicator, in the middle.
Rectangle {
id: circle
required property var modelData
readonly property var w: modelData
z: w.order
anchors.centerIn: parent
width: 2 * w.radius + 2 * w.thickness
height: width
radius: width / 2
color: w.inside
border.width: w.thickness
border.color: w.ring
Text {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: circle.w.text
color: circle.w.textColor
font.pixelSize: circle.w.radius / 3
}
}
}
}
+11 -2
View File
@@ -15,6 +15,9 @@ Item {
required property var bubble
required property bool primary
required property url wallpaper
// Off for the picture in the window that asks which lock screen to use:
// nothing to type into, no keys taken.
property bool interactive: true
property date now: new Date()
Timer {
@@ -71,6 +74,7 @@ Item {
// keys into the password.
MouseArea {
anchors.fill: parent
enabled: root.interactive
hoverEnabled: true
acceptedButtons: Qt.AllButtons
onPositionChanged: root.lock.activity()
@@ -150,7 +154,8 @@ Item {
selectionColor: Theme.accent
font.pixelSize: 15
clip: true
focus: true
focus: root.interactive
enabled: root.interactive
// The dots show how far it is; a blinking bar in the middle
// of the empty field only cuts the word in it in two.
cursorDelegate: Item {}
@@ -162,7 +167,11 @@ Item {
root.lock.activity()
event.accepted = false
}
Component.onCompleted: forceActiveFocus()
Component.onCompleted: {
if (root.interactive) {
forceActiveFocus()
}
}
}
Text {
anchors.centerIn: parent
+1
View File
@@ -32,6 +32,7 @@ void UserConfig::load()
m_enabled = kv.boolean(QStringLiteral("Enabled"), false);
m_lockWallpaper = kv.value(QStringLiteral("LockWallpaper"));
m_lockBlur = kv.boolean(QStringLiteral("LockBlur"), false);
m_lockScreenStyle = kv.value(QStringLiteral("LockScreenStyle"));
m_lockScreen = kv.boolean(QStringLiteral("LockScreen"), true);
m_scanOnWake = kv.boolean(QStringLiteral("ScanOnWake"), true);
m_scanOnLock = kv.boolean(QStringLiteral("ScanOnLock"), false);
+9
View File
@@ -29,6 +29,14 @@ public:
{
return m_lockWallpaper;
}
// Where the lock screen is a program of the user's (Hyprland, Niri):
// "own" to lock with face-unlock's, "yours" to keep that program, which
// then shows the bubble as a line of text (see LockText). Empty until
// the user picked one.
QString lockScreenStyle() const
{
return m_lockScreenStyle;
}
bool lockBlur() const
{
return m_lockBlur;
@@ -90,6 +98,7 @@ private:
QFileSystemWatcher m_watcher;
bool m_enabled = false;
QString m_lockWallpaper;
QString m_lockScreenStyle;
bool m_lockBlur = false;
bool m_lockScreen = true;
bool m_scanOnWake = true;
+7 -1
View File
@@ -125,7 +125,13 @@ fu_do_enable() {
esac
;;
esac
if fu_pam_lockers_here && fu_pam_lockers_enabled; then
# Where the lock screen is a program of its own: face-unlock's, with the
# bubble, or that program with a line of text. Asked once.
if fu_own_lock_here && [[ -n ${WAYLAND_DISPLAY:-} && -z $(fu_config_get LockScreenStyle) ]]; then
fu_say " $(fu_msg "Pick your lock screen in the window.")"
fu_lock_choose || fu_note "$(fu_msg "No lock screen picked. You can do it later under Settings.")"
fi
if fu_pam_lockers_here && fu_pam_lockers_enabled && [[ $(fu_config_get LockScreenStyle) != own ]]; then
fu_note "$(fu_msg "The lock screen (%s) takes your face too. If it does not scan when you come back, press Enter on the empty password field." "$(fu_pam_lockers_list)")"
fi
+2
View File
@@ -156,6 +156,8 @@ fu_head() { printf '\n%s%s%s\n\n' "$FU_C_BOLD$FU_C_BLUE" "$*" "$FU_C_RESET"; }
fu_ok() { FU_UI_NOTICES+=("$FU_C_GREEN✔$FU_C_RESET $*"); printf '%s✔%s %s\n' "$FU_C_GREEN" "$FU_C_RESET" "$*"; }
fu_bad() { FU_UI_NOTICES+=("$FU_C_RED✘$FU_C_RESET $*"); printf '%s✘%s %s\n' "$FU_C_RED" "$FU_C_RESET" "$*" >&2; }
fu_note() { FU_UI_NOTICES+=("$FU_C_DIM•$FU_C_RESET $*"); printf '%s•%s %s\n' "$FU_C_DIM" "$FU_C_RESET" "$*"; }
# A line of config to copy, under a note.
fu_code() { FU_UI_NOTICES+=(" $*"); printf ' %s\n' "$*"; }
fu_have() { command -v "$1" > /dev/null 2>&1; }
+28 -7
View File
@@ -200,6 +200,8 @@ fu_value_label() {
AnimationSpeed:slow) fu_msg "slow" ;;
ScanSeconds:*) fu_msg "%s seconds" "$2" ;;
Camera:auto) fu_msg "automatic" ;;
LockScreenStyle:own) fu_msg "face-unlock's" ;;
LockScreenStyle:*) fu_msg "yours" ;;
*) printf '%s' "$2" ;;
esac
}
@@ -277,10 +279,11 @@ fu_ui_status() {
# scope user (this user's file), sys (the system file, through sudo),
# pam (the service of that name, through sudo), or group for a
# heading, with only the label after it
# type bool, choice (steps through the choices), camera, or path (typed
# in; empty for the desktop's picture)
# needs a bool setting this one does nothing without; it is dimmed while
# that is off
# type bool, choice (steps through the choices), camera, path (typed
# in; empty for the desktop's picture), or lockstyle (the window
# with the two lock screens, see fu_lock_choose)
# needs a bool setting this one does nothing without, or Key=value; it is
# dimmed while that is not so
# only lockers: only where the lock screen is a program of its own with a
# PAM file (Hyprland, Niri), see fu_pam_lockers_here; ownlock: only
# where face-unlock's own lock screen can be used
@@ -289,8 +292,9 @@ FU_SETTINGS=(
"user|LockScreen|bool|yes|Unlock with your face"
"user|ScanOnWake|bool|yes|Scan when you come back||LockScreen"
"user|ScanOnLock|bool|no|Scan right after locking||LockScreen"
"user|LockWallpaper|path||Wallpaper|||ownlock"
"user|LockBlur|bool|no|Blur the wallpaper|||ownlock"
"user|LockScreenStyle|lockstyle|yours|Which lock screen|||ownlock"
"user|LockWallpaper|path||Wallpaper||LockScreenStyle=own|ownlock"
"user|LockBlur|bool|no|Blur the wallpaper||LockScreenStyle=own|ownlock"
"group|Password prompts"
"pam|sudo|bool|no|sudo in a terminal"
"pam|polkit-1|bool|no|Admin prompts"
@@ -328,6 +332,7 @@ fu_setting_help() {
ScanOnWake:*) fu_msg "Scans when you press a key or move the mouse on the lock screen, and when the computer wakes up." ;;
ScanOnLock:*) fu_msg "Scans as soon as the screen locks. Off by default: if you lock it yourself, it would unlock again right away." ;;
sudo:*) fu_msg "sudo takes your face instead of the password. No match: you type the password as usual." ;;
LockScreenStyle:*) fu_msg "face-unlock's lock screen shows the bubble, and you lock with \`face-unlock lock\`. Or keep yours: hyprlock then shows a line of text at the top. Enter opens the choice." ;;
LockWallpaper:*) fu_msg "The picture behind face-unlock's own lock screen (\`face-unlock lock\`). Empty: the one on your desktop. Or a file, a folder to take one from at random, or \`none\`. Enter to type it." ;;
LockBlur:*) fu_msg "Blurs the picture behind face-unlock's own lock screen." ;;
lockscreens:*) fu_msg "The lock screen takes your face in its password check. Press Enter on the empty field to scan. Found here: %s." "$(fu_pam_lockers_list)" ;;
@@ -483,6 +488,18 @@ _fu_setting_change() {
bool) if fu_is_true "$current"; then next=no; else next=yes; fi ;;
choice) next="$(_fu_next_choice "$current" "$options" "$step")" ;;
camera) next="$(_fu_next_camera "$current" "$step")" ;;
lockstyle)
# The window with the pictures where there is a screen for it,
# else the other one of the two.
if [[ -n ${WAYLAND_DISPLAY:-} ]]; then
fu_ui_cooked fu_lock_choose
elif [[ $current == own ]]; then
fu_lock_style_set yours
else
fu_lock_style_set own
fi
return 0
;;
path)
printf '\n %s ' "$(fu_msg "Picture or folder:")"
fu_ui_read_line "$current" || return 0
@@ -612,7 +629,11 @@ fu_ui_settings() {
before="${FU_C_BLUE}◂${FU_C_RESET} " after=" ${FU_C_BLUE}▸${FU_C_RESET}"
fi
dim=''
[[ -n ${needs[i]} ]] && ! fu_is_true "${current[${needs[i]}]}" && dim="$FU_C_DIM"
case "${needs[i]}" in
'') ;;
*=*) [[ ${current[${needs[i]%%=*}]} == "${needs[i]#*=}" ]] || dim="$FU_C_DIM" ;;
*) fu_is_true "${current[${needs[i]}]}" || dim="$FU_C_DIM" ;;
esac
pad=$(( width + 2 - widths[i] ))
if (( i == selected )); then marker="${FU_C_BLUE}▸${FU_C_RESET} "; else marker=' '; fi
printf -v row ' %s%s%s%s%*s%s%s%s' "$marker" "$dim" "${labels[i]}" "$FU_C_RESET" "$pad" '' "$before" "$dim$shown$FU_C_RESET" "$after"
+123
View File
@@ -193,6 +193,129 @@ fu_gnome_extension_disable() {
gsettings set org.gnome.shell enabled-extensions "$list" > /dev/null 2>&1 || true
}
# ---------------------------------------------------------------------------
# The lock screen where it is a program of its own (Hyprland, Niri)
# ---------------------------------------------------------------------------
# Either face-unlock's (`face-unlock lock`, with the bubble), or the user's
# own program. hyprlock then shows what the bubble would as a line of text: a
# label in its config reads a file the agent writes (see
# src/agent/locktext.h). Asked once in a window, and under Settings.
FU_HYPRLOCK_SNIPPET="$FU_XDG_CONFIG/$FU_NAME/hyprlock.conf"
FU_HYPRLOCK_MARK="# $FU_NAME: what face unlock is doing, at the top"
fu_own_lock_here() {
[[ $FU_DESKTOP != plasma && $FU_DESKTOP != gnome ]]
}
# fu_hyprlock_text_on
# 0 done, 1 could not write, 2 no hyprlock, 3 hyprlock has no config here.
fu_hyprlock_text_on() {
local conf="$FU_XDG_CONFIG/hypr/hyprlock.conf"
fu_have hyprlock || return 2
[[ -f $conf ]] || return 3
mkdir -p "${FU_HYPRLOCK_SNIPPET%/*}" || return 1
cat > "$FU_HYPRLOCK_SNIPPET" <<- EOF || return 1
# Written by $FU_NAME: what face unlock is doing, as a line at the top of
# hyprlock. Picking $FU_NAME's lock screen in its menu takes it out again.
label {
monitor =
text = cmd[update:0:1] cat "\$XDG_RUNTIME_DIR/$FU_NAME/lock-text" 2>/dev/null
color = rgba(255, 255, 255, 0.95)
font_size = 20
shadow_passes = 2
shadow_size = 3
halign = center
valign = top
position = 0, -48
zindex = 10
}
EOF
grep -qxF "$FU_HYPRLOCK_MARK" "$conf" && return 0
# With ~ where it can, like a hand-written config. hyprlock expands it.
local snippet="$FU_HYPRLOCK_SNIPPET"
# shellcheck disable=SC2088 # for hyprlock, not for the shell
[[ $snippet == "$HOME"/* ]] && snippet="~/${snippet#"$HOME"/}"
printf '\n%s\nsource = %s\n' "$FU_HYPRLOCK_MARK" "$snippet" >> "$conf" || return 1
}
# fu_hyprlock_text_off
# The mark, the source line under it, and the empty lines before it. Written
# back in place, so a hyprlock.conf that is a link to a dotfile stays one.
fu_hyprlock_text_off() {
local conf="$FU_XDG_CONFIG/hypr/hyprlock.conf" content
rm -f "$FU_HYPRLOCK_SNIPPET"
[[ -f $conf ]] && grep -qxF "$FU_HYPRLOCK_MARK" "$conf" || return 0
content="$(awk -v mark="$FU_HYPRLOCK_MARK" '
$0 == mark { held = ""; skip = 1; next }
skip && /^[[:space:]]*source[[:space:]]*=/ { skip = 0; next }
{ skip = 0 }
/^[[:space:]]*$/ { held = held $0 "\n"; next }
{ printf "%s", held; held = ""; print }
' "$conf")" || return 1
printf '%s\n' "$content" > "$conf"
}
# fu_own_lock_hint
# Where face-unlock's lock screen has to be started from: the user's key and
# idle lock, which are theirs to change.
fu_own_lock_hint() {
# shellcheck disable=SC2088 # shown to the user, not a path to open
case "$FU_DESKTOP" in
hyprland)
if [[ -f $FU_XDG_CONFIG/hypr/hyprland.lua ]]; then
fu_note "$(fu_msg "To lock with it, add this to %s, and set %s in %s:" "~/.config/hypr/hyprland.lua" "lock_cmd = $FU_NAME lock" "hypridle.conf")"
fu_code "hl.bind(\"SUPER + L\", hl.dsp.exec_cmd(\"$FU_NAME lock\"))"
else
fu_note "$(fu_msg "To lock with it, add this to %s, and set %s in %s:" "~/.config/hypr/hyprland.conf" "lock_cmd = $FU_NAME lock" "hypridle.conf")"
fu_code "bind = SUPER, L, exec, $FU_NAME lock"
fi
;;
niri)
fu_note "$(fu_msg "To lock with it, add this to the binds in %s, and use %s in swayidle:" "~/.config/niri/config.kdl" "$FU_NAME lock")"
fu_code "Mod+Alt+L { spawn \"$FU_NAME\" \"lock\"; }"
;;
*)
fu_note "$(fu_msg "To lock with it, run %s where your lock screen is started now." "$FU_NAME lock")"
;;
esac
}
# fu_lock_style_set <own|yours>
fu_lock_style_set() {
# shellcheck disable=SC2088 # shown to the user, not a path to open
local conf="~/.config/hypr/hyprlock.conf"
case "$1" in
own)
fu_config_set LockScreenStyle own || { fu_bad "$(fu_msg "Could not save the setting.")"; return 1; }
fu_hyprlock_text_off
fu_ok "$(fu_msg "You lock with face-unlock's lock screen now.")"
fu_own_lock_hint
;;
yours)
fu_config_set LockScreenStyle yours || { fu_bad "$(fu_msg "Could not save the setting.")"; return 1; }
fu_hyprlock_text_on
case $? in
0) fu_ok "$(fu_msg "hyprlock shows what face unlock is doing at the top, from the next time it locks.")" ;;
1) fu_bad "$(fu_msg "Could not add the line to %s." "$conf")" ;;
2) fu_ok "$(fu_msg "Your lock screen stays as it is. Press Enter on the empty password field to scan.")" ;;
3) fu_note "$(fu_msg "hyprlock has no %s, so it cannot show the text." "$conf")" ;;
esac
;;
*) return 1 ;;
esac
}
# fu_lock_choose
# Asks in a window which lock screen to use, and carries it out. 1 when the
# window was closed or cannot open.
fu_lock_choose() {
local choice
[[ -n ${WAYLAND_DISPLAY:-} ]] || return 1
choice="$("$FU_AGENT" --choose-lock-screen 2> /dev/null)" || return 1
fu_lock_style_set "$choice"
}
fu_agent_disable() {
systemctl --user disable --now "$FU_UNIT_AGENT" > /dev/null 2>&1 || true
}