Publish Framework tray enhancements and idle fingerprint retry

This commit is contained in:
ajp_anton
2026-09-20 20:48:34 +00:00
parent 61ef199667
commit ddb912d4ba
25 changed files with 654 additions and 61 deletions
+6 -3
View File
@@ -65,9 +65,12 @@ display is turned off or the system resumes from sleep.
The package applies a narrow patch to Plasma 6.7's lock-screen QML and reapplies
it after `plasma-desktop` updates. An unknown future layout is left unchanged
instead of being modified speculatively. Removing the package restores Plasma's
original behaviour. It retries lock-screen authentication after resume and the
automatic-lock grace period, but does not modify `kscreenlocker` authentication
code or `fprintd`.
original behaviour. It retries lock-screen authentication after resume, the
automatic-lock grace period and idle fingerprint timeouts. Failed scans do not
trigger automatic retries. It does not modify `kscreenlocker` authentication
code or restart `fprintd`; retry support depends on the installed KScreenLocker.
An intermittent missing fingerprint prompt can still occur; pressing a key may
restore it. The idle retry is a workaround, not a complete fix for that issue.
### Plasma task-group shortcuts
+18 -4
View File
@@ -17,12 +17,19 @@ Successful saves become the new Undo baseline. If a hardware operation fails,
earlier operations may already have applied; remaining edits stay pending.
CPU slider bounds are read from Linux's hardware-frequency limits.
The Monitor page has CPU/GPU frequency, fan RPM, temperature and battery graphs.
The Monitor page has CPU/GPU frequency, power, fan RPM, temperature and battery graphs.
The battery heading shows full capacity, health and cycle count when available.
Capacity calculated from charge capacity and nominal voltage is approximate.
Temperature labels distinguish CPU-area/board sensors from die readings;
raw sysfs paths remain available in tooltips. CPU frequency is Linux's reported
average across CPU policies, not an instantaneous measurement of every core.
average across readable CPU policies, not an instantaneous measurement of every
core or a utilisation-weighted average. On detected Intel hybrid CPUs, separate
P-core and E-core averages are shown by default; the combined average remains
selectable. Each group also offers its highest reported frequency at each sample,
disabled by default. These are readings, not hardware limits or historical peaks.
All averages and maxima are also available as tray readings. Core membership uses
Linux's hybrid CPU lists, not clock-speed guesses. Without reliable grouping,
only the combined average is shown. AMD core-type grouping is not yet supported.
GPU GT domains are shown separately rather than assuming they are identical.
Click a coloured legend to toggle its series. Hidden series remain in the legend,
greyed out and crossed out. Main temperature sensors are shown first; additional
@@ -75,8 +82,10 @@ Sampling choices are 0.5/1/2/4 seconds for frequencies, power, fan and temperatu
15/30/60/120 seconds for the battery graph. Tray autostart is optional.
Closing the window leaves monitoring in the tray; Quit exits the application.
The Tray icon tab supports one to ten independently configured icons, each showing
an application icon, a history graph, or a number. Settings cards wrap to fit the
window. New icons default to the application icon; your existing configuration
an application icon, a history graph, or a number. Number mode offers an optional
second reading, displayed beneath the first; **None** keeps a single number.
Settings cards wrap to fit the window. New icons default to the application icon;
your existing configuration
becomes Icon 1. Reducing the count keeps hidden drafts until Save and Apply;
saving retains only visible icons. The Move icon arrows swap all settings with
the adjacent icon while keeping the position numbers fixed. Undo restores the
@@ -106,6 +115,11 @@ choice. Hover information is configured separately: CPU usage; battery level
and remaining/full energy in mWh, followed by signed power and time to the charge
target; fan RPM and duty percentage; and the main CPU, memory, NVMe, battery and
board temperatures. Multiple temperatures have a heading and indented lines.
Detected power sensors have individual hover checkboxes, off by default. Multiple
selected power readings are grouped under a heading; battery power remains part
of the Battery option. Power, frequency and temperature readings use one decimal
between -10 and 10 (exclusive), and otherwise round to the nearest integer.
Numeric tray frequencies remain in GHz; graph readings retain their labelled units.
Missing sensors are unavailable in the settings page. Batteries reporting only
charge are converted to mWh using nominal voltage.
Choose zero to three top CPU applications, displayed below CPU usage in descending
@@ -1,6 +1,6 @@
Name: framework-laptop-tools
Version: 0.1.0
Release: 25%{?dist}
Release: 28%{?dist}
Summary: Hardware controls and monitoring for Framework Laptop 13 Pro
License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools
@@ -56,6 +56,16 @@ install -Dpm 0644 %{SOURCE1} %{buildroot}%{_licensedir}/%{name}/LICENSE
%{_unitdir}/framework-laptop-tools-fan-resume.service
%{_unitdir}/framework-laptop-tools-cpu.service
%changelog
* Fri Sep 18 2026 fedora-tools contributors - 0.1.0-28
- Add an optional second numeric tray reading and P/E-core maximum frequencies
* Fri Sep 18 2026 fedora-tools contributors - 0.1.0-27
- Show fractional power, frequency and temperature readings below ten
- Add selectable power sensors to each tray icon's hover information
* Sat Sep 12 2026 fedora-tools contributors - 0.1.0-26
- Add detected P-core and E-core frequency averages to Monitor and tray readings
* Sat Sep 12 2026 fedora-tools contributors - 0.1.0-25
- Add multiple tray icons, opacity controls and component power monitoring
- Refine graph columns, live hover readings and sampling-interval changes
+3 -1
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
#include "chart.h"
#include "graphdata.h"
#include "readingformat.h"
#include <QPainter>
#include <QMouseEvent>
#include <QToolTip>
@@ -230,7 +231,8 @@ QString Chart::readingAt(qint64 time) const
if (next != points.cbegin()) interval = std::max(interval, intervalAt((next - 1)->x()));
const bool valid = !sleeping && closest && std::abs(closest->x() - time) <= interval * 1.5
&& std::isfinite(closest->y()) && !crossesSleep(std::min(closest->x(), double(time)), std::max(closest->x(), double(time)));
lines.append(it->name + ": " + (valid ? number(closest->y()) + " " + it->unit : ""));
const bool adaptive = it->unit == "W" || it->unit == "MHz" || it->unit == "°C";
lines.append(it->name + ": " + (valid ? (adaptive ? formatReading(closest->y()) : number(closest->y())) + " " + it->unit : ""));
}
return lines.join('\n');
}
+32 -8
View File
@@ -72,7 +72,26 @@ QVector<Sensor> temperatureSensors(const QString &sys)
QVector<Sensor> frequencySensors(const QString &sys)
{
QVector<Sensor> sensors;
sensors.append({"cpu", "CPU average (reported)", sys + "/devices/system/cpu/cpufreq", "MHz", .001});
const QString root = sys + "/devices/system/cpu/cpufreq";
const auto groups = cpuPolicyGroups(sys);
for (const auto &group : {QString("p"), QString("e")}) {
if (!groups.contains(group)) continue;
Sensor sensor{"cpu/" + group, group.toUpper() + "-cores average (reported)", root, "MHz", .001, true};
for (const auto &policy : groups[group])
sensor.frequencyPaths.append(root + "/" + policy + "/scaling_cur_freq");
sensors.append(sensor);
}
// Keep the original ID for saved tray selections and combined-average history.
sensors.append({"cpu", "CPU average (reported)", root, "MHz", .001, groups.isEmpty()});
const int groupCount = sensors.size() - 1;
for (int i = 0; i < groupCount; ++i) {
auto sensor = sensors[i];
sensor.id += "/max";
sensor.name = sensor.id.section('/', 1, 1).toUpper() + "-cores maximum (reported)";
sensor.primary = false;
sensor.maximum = true;
sensors.append(sensor);
}
const QDir drm(sys + "/class/drm");
for (const auto &card : drm.entryList({"card*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
QDirIterator files(drm.filePath(card + "/device"), {"act_freq"}, QDir::Files,
@@ -80,22 +99,27 @@ QVector<Sensor> frequencySensors(const QString &sys)
while (files.hasNext()) {
const QString path = files.next();
const QString relative = path.mid(drm.filePath(card + "/device/").size());
sensors.append({card + "/" + relative, "GPU " + card + " " + relative.section('/', 1, 1), path, "MHz", 1});
sensors.append({card + "/" + relative, "GPU " + card + " " + relative.section('/', 1, 1), path, "MHz", 1, true});
}
}
return sensors;
}
std::optional<double> sensorValue(const Sensor &sensor)
{
if (sensor.id == "cpu" || !sensor.frequencyPaths.isEmpty()) {
QStringList paths = sensor.frequencyPaths;
if (sensor.id == "cpu") {
const QDir dir(sensor.path);
double total = 0;
int count = 0;
for (const auto &policy : dir.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
const auto n = readNumber(dir.filePath(policy + "/scaling_cur_freq"));
if (n) { total += *n; ++count; }
for (const auto &policy : dir.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot))
paths.append(dir.filePath(policy + "/scaling_cur_freq"));
}
return count ? std::optional<double>(total / count * sensor.scale) : std::nullopt;
double total = 0, maximum = 0;
int count = 0;
for (const auto &path : paths) {
const auto n = readNumber(path);
if (n) { total += *n; maximum = std::max(maximum, *n); ++count; }
}
return count ? std::optional<double>((sensor.maximum ? maximum : total / count) * sensor.scale) : std::nullopt;
}
if (sensor.unit == "°C") {
const QString fault = sensor.path.chopped(6) + "_fault";
+3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QString>
#include <QStringList>
#include <QVariantMap>
#include <QVector>
#include <optional>
@@ -9,6 +10,8 @@ struct Sensor {
QString id, name, path, unit;
double scale = 1;
bool primary = false;
QStringList frequencyPaths;
bool maximum = false;
};
QString readText(const QString &path);
std::optional<double> readNumber(const QString &path);
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QString>
#include <cmath>
inline QString formatReading(double value)
{
if (!std::isfinite(value)) return QStringLiteral("");
const bool fractional = std::abs(value) < 10;
const auto text = QString::number(fractional ? value : std::round(value), 'f', fractional ? 1 : 0);
return text == "-0.0" ? QStringLiteral("0.0") : text;
}
+13 -5
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: MIT
#include "tooltip.h"
#include "readingformat.h"
#include <cmath>
#include <algorithm>
@@ -15,9 +16,9 @@ QVector<TooltipSensor> tooltipSensors(const QVector<Sensor> &sensors)
}
return result;
}
bool tooltipEnabled(const QVariantMap &settings, const QString &key)
bool tooltipEnabled(const QVariantMap &settings, const QString &key, bool fallback)
{
return settings.value("tray/hover/" + key, true).toBool();
return settings.value("tray/hover/" + key, fallback).toBool();
}
int tooltipAppCount(const QVariantMap &settings)
{
@@ -36,7 +37,7 @@ QString batteryTooltip(const QVariantMap &battery, int limit, const QVariantMap
};
text += " · " + energy("remainingMWh") + " mWh / " + energy("fullMWh") + " mWh";
const auto watts = batteryRate(battery);
text += "\n\u2003" + (watts ? (*watts > 0 ? "+" : "") + QString::number(*watts, 'f', 1) + " W" : "— W");
text += "\n\u2003" + (watts ? (*watts > 0 ? "+" : "") + formatReading(*watts) + " W" : "— W");
const bool charging = battery["state"] == "Charging", discharging = battery["state"] == "Discharging";
double seconds = 0;
bool approximate = false;
@@ -60,7 +61,8 @@ QString batteryTooltip(const QVariantMap &battery, int limit, const QVariantMap
return text;
}
QString trayTooltip(const QVariantMap &settings, const QMap<QString, double> &values,
const QVector<TooltipSensor> &temperatures, const QString &battery, const QStringList &topApps)
const QVector<TooltipSensor> &temperatures, const QString &battery, const QStringList &topApps,
const QVector<Sensor> &powerSensors)
{
const auto reading = [&](const QString &id, int decimals) {
const auto it = values.constFind(id);
@@ -75,10 +77,16 @@ QString trayTooltip(const QVariantMap &settings, const QMap<QString, double> &va
}
if (tooltipEnabled(settings, "battery")) lines << battery;
if (tooltipEnabled(settings, "fan")) lines << "Fan speed: " + reading("fan", 0) + " RPM (" + reading("fan-duty", 0) + " %)";
QStringList power;
for (const auto &sensor : powerSensors)
if (tooltipEnabled(settings, sensor.id, false))
power << sensor.name + ": " + formatReading(values.value(sensor.id, NAN)) + " W";
if (power.size() > 1) lines << "Power consumption:";
for (const auto &line : power) lines << (power.size() > 1 ? "\u2003" : "") + line;
QStringList temps;
for (const auto &sensor : temperatures)
if (!sensor.id.isEmpty() && tooltipEnabled(settings, "temperature/" + sensor.key))
temps << sensor.name + " " + reading(sensor.id, 0) + "°C";
temps << sensor.name + " " + formatReading(values.value(sensor.id, NAN)) + "°C";
if (temps.size() > 1) lines << "Temperatures:";
for (const auto &temp : temps) lines << (temps.size() > 1 ? "\u2003" : "") + temp;
return lines.join('\n');
+3 -2
View File
@@ -4,8 +4,9 @@
struct TooltipSensor { QString key, name, id; };
QVector<TooltipSensor> tooltipSensors(const QVector<Sensor> &sensors);
bool tooltipEnabled(const QVariantMap &settings, const QString &key);
bool tooltipEnabled(const QVariantMap &settings, const QString &key, bool fallback = true);
int tooltipAppCount(const QVariantMap &settings);
QString batteryTooltip(const QVariantMap &battery, int limit, const QVariantMap &upower);
QString trayTooltip(const QVariantMap &settings, const QMap<QString, double> &values,
const QVector<TooltipSensor> &temperatures, const QString &battery, const QStringList &topApps);
const QVector<TooltipSensor> &temperatures, const QString &battery, const QStringList &topApps,
const QVector<Sensor> &powerSensors = {});
+24 -5
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
#include "tray.h"
#include "graphdata.h"
#include "readingformat.h"
#include <QPainter>
#include <cmath>
#include <algorithm>
@@ -30,7 +31,8 @@ QRectF trayPlotRect(bool border)
// Two-pixel border; reserve space for the graph's vertical padding inside it.
return border ? QRectF(3, 3, 58, 58) : QRectF(1, 1, 62, 62);
}
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style, qint64 now)
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style, qint64 now,
std::optional<TrayReading> second)
{
QPixmap pixmap(64, 64); pixmap.fill(style.backgroundColor);
QPainter p(&pixmap); p.setRenderHint(QPainter::Antialiasing);
@@ -67,11 +69,28 @@ QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &u
}
p.restore();
} else {
const QString value = !std::isfinite(latest) ? "" : unit == "MHz"
? QString::number(latest / 1000, 'f', 1) : QString::number(std::round(latest), 'f', 0);
const bool stacked = !graph && second.has_value();
const auto drawNumber = [&](const TrayReading &reading, const QRectF &rect) {
const bool adaptive = reading.unit == "MHz" || reading.unit == "W" || reading.unit == "°C";
const QString value = !std::isfinite(reading.value) ? "" : adaptive
? formatReading(reading.unit == "MHz" ? reading.value / 1000 : reading.value)
: QString::number(std::round(reading.value), 'f', 0);
QFont font; font.setBold(true); font.setPixelSize(42);
while (QFontMetrics(font).horizontalAdvance(value) > area.width() && font.pixelSize() > 12) font.setPixelSize(font.pixelSize() - 1);
p.setFont(font); p.drawText(area, Qt::AlignCenter, value);
while (font.pixelSize() > 12 && (QFontMetrics(font).horizontalAdvance(value) > rect.width()
|| (stacked && QFontMetrics(font).tightBoundingRect(value).height() > rect.height() - 2)))
font.setPixelSize(font.pixelSize() - 1);
p.setFont(font);
if (stacked) {
// Centre the visible glyphs, not the font's extra line spacing.
const auto bounds = QFontMetricsF(font).tightBoundingRect(value);
p.drawText(rect.center() - bounds.center(), value);
} else p.drawText(rect, Qt::AlignCenter, value);
};
if (stacked) {
const double height = area.height() / 2;
drawNumber({latest, unit}, QRectF(area.left(), area.top(), area.width(), height));
drawNumber(*second, QRectF(area.left(), area.top() + height, area.width(), height));
} else drawNumber({latest, unit}, area);
}
return QIcon(pixmap);
}
+5 -1
View File
@@ -23,5 +23,9 @@ struct TrayStyle {
qint64 historyMs = 60000;
};
QRectF trayPlotRect(bool border);
struct TrayReading {
double value;
QString unit;
};
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style,
qint64 now = QDateTime::currentMSecsSinceEpoch());
qint64 now = QDateTime::currentMSecsSinceEpoch(), std::optional<TrayReading> second = {});
+24 -3
View File
@@ -53,6 +53,11 @@ TrayPage::TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QW
auto *graphForm = new QFormLayout(graphOptions); graphForm->setContentsMargins(24, 0, 0, 0);
graphForm->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
form->addRow(graphOptions); form->addRow("Reading:", m_metric);
m_secondMetric = new QComboBox; m_secondMetric->setObjectName("traySecondMetric");
m_secondMetric->addItem("None", "");
for (const auto &sensor : sensors) m_secondMetric->addItem(sensor.name + " (" + sensor.unit + ")", sensor.id);
m_secondMetric->setCurrentIndex(std::max(0, m_secondMetric->findData(m_values.value("tray/secondMetric", ""))));
form->addRow("Second reading:", m_secondMetric);
auto *appearance = new QWidget; auto *colors = new QFormLayout(appearance); colors->setContentsMargins(0, 0, 0, 0);
colors->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
auto colorRow = [&](QFormLayout *target, const QString &label, const QString &key, const QColor &fallback, bool alpha = false) {
@@ -132,10 +137,11 @@ TrayPage::TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QW
m_values.insert("tray/outside", m_outside->currentData()); syncOutside(); if (!m_loading) Q_EMIT settingsChanged();
});
syncOutside(); layout->addWidget(m_graphSettings);
auto syncMode = [this, appearance, graphOptions] {
auto syncMode = [this, form, appearance, graphOptions] {
const bool telemetry = mode() != "icon";
m_metric->setEnabled(telemetry); appearance->setVisible(telemetry); m_graphSettings->setVisible(mode() == "graph");
graphOptions->setVisible(mode() == "graph");
form->setRowVisible(m_secondMetric, mode() == "number");
};
connect(m_mode, &QComboBox::currentIndexChanged, this, [this, syncMode] {
m_values.insert("tray/mode", m_mode->currentData()); syncMode(); if (!m_loading) Q_EMIT settingsChanged();
@@ -143,6 +149,9 @@ TrayPage::TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QW
connect(m_metric, &QComboBox::currentIndexChanged, this, [this] {
m_values.insert("tray/metric", m_metric->currentData()); loadScale(); if (!m_loading) Q_EMIT settingsChanged();
});
connect(m_secondMetric, &QComboBox::currentIndexChanged, this, [this] {
m_values.insert("tray/secondMetric", m_secondMetric->currentData()); if (!m_loading) Q_EMIT settingsChanged();
});
connect(m_minimum, &QDoubleSpinBox::valueChanged, this, [this](double value) {
m_maximum->setMinimum(value + 1); m_values.insert(scaleKey("minimum"), value); if (!m_loading) Q_EMIT settingsChanged();
});
@@ -152,10 +161,12 @@ TrayPage::TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QW
loadScale(); syncMode();
m_values.insert("tray/mode", m_mode->currentData());
m_values.insert("tray/metric", m_metric->currentData());
m_values.insert("tray/secondMetric", m_secondMetric->currentData());
m_values.insert("tray/outside", m_outside->currentData());
m_reload.append([this, syncMode, syncOutside] {
m_mode->setCurrentIndex(std::max(0, m_mode->findData(m_values.value("tray/mode", "icon"))));
m_metric->setCurrentIndex(std::max(0, m_metric->findData(m_values.value("tray/metric", "cpu-usage"))));
m_secondMetric->setCurrentIndex(std::max(0, m_secondMetric->findData(m_values.value("tray/secondMetric", ""))));
m_outside->setCurrentIndex(std::max(0, m_outside->findData(m_values.value("tray/outside", "clamp"))));
syncMode(); syncOutside();
});
@@ -178,6 +189,15 @@ TrayPage::TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QW
appOptions->setEnabled(cpu->isChecked()); connect(cpu, &QCheckBox::toggled, appOptions, &QWidget::setEnabled);
check(hoverForm, "Battery", "hover/battery", true);
check(hoverForm, "Fan", "hover/fan", true);
auto *powerOptions = new QWidget;
auto *powerForm = new QFormLayout(powerOptions); powerForm->setContentsMargins(24, 0, 0, 0);
for (const auto &sensor : sensors)
if (sensor.unit == "W" && sensor.id.startsWith("power/"))
check(powerForm, sensor.name, "hover/" + sensor.id, false);
if (powerForm->rowCount()) {
hoverForm->addRow(new QLabel("Power consumption:"));
hoverForm->addRow(powerOptions);
} else delete powerOptions;
hoverForm->addRow(new QLabel("Temperature:"));
auto *temperatures = new QWidget; auto *tempForm = new QFormLayout(temperatures); tempForm->setContentsMargins(24, 0, 0, 0);
for (const auto &sensor : tooltipSensors(sensors)) {
@@ -205,7 +225,8 @@ void TrayPage::loadScale()
const bool frequency = unit == "MHz", temperature = unit == "°C", rate = unit == "W";
m_scaleLayout->setRowVisible(m_minimum, temperature || rate); m_scaleLayout->setRowVisible(m_maximum, temperature || frequency || rate);
for (auto *spin : {m_minimum, m_maximum}) { spin->setDecimals(0); spin->setRange(rate ? -300 : temperature ? -50 : 0, frequency ? 20000 : 300); spin->setSuffix(" " + unit); }
double maximum = frequency ? (metric().id == "cpu" ? cpuLimits().value("high", 5000).toDouble() : 3000) : rate ? 75 : 100;
const bool cpuFrequency = metric().id == "cpu" || metric().id.startsWith("cpu/");
double maximum = frequency ? (cpuFrequency ? cpuLimits("/sys", metric().id.section('/', 1, 1)).value("high", 5000).toDouble() : 3000) : rate ? 75 : 100;
const double low = m_values.value(scaleKey("minimum"), rate && !metric().id.startsWith("power/") ? -75 : 0).toDouble();
maximum = m_values.value(scaleKey("maximum"), maximum).toDouble();
m_minimum->setValue(low); m_maximum->setMinimum(m_minimum->value() + 1); m_maximum->setValue(maximum);
@@ -345,7 +366,7 @@ QVector<TrayConfiguration> TrayIconsPage::configurations() const
QVector<TrayConfiguration> result;
for (int i = 0; i < m_count->value(); ++i) {
const auto *page = m_pages[i];
result.append({page->mode(), page->metric(), page->iconStyle(), page->draft()});
result.append({page->mode(), page->metric(), page->iconStyle(), page->draft(), page->secondMetric()});
}
return result;
}
+6 -1
View File
@@ -19,6 +19,7 @@ struct TrayConfiguration {
Sensor metric;
TrayStyle style;
QVariantMap values;
std::optional<Sensor> secondMetric;
};
class TrayPage : public QWidget {
@@ -28,6 +29,10 @@ public:
TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QWidget *parent = nullptr);
QString mode() const { return m_mode->currentData().toString(); }
Sensor metric() const { return m_sensors[m_metric->currentIndex()]; }
std::optional<Sensor> secondMetric() const {
return m_secondMetric->currentIndex() > 0
? std::optional<Sensor>(m_sensors[m_secondMetric->currentIndex() - 1]) : std::nullopt;
}
TrayStyle iconStyle() const;
QVariantMap draft() const { return m_values; }
void load(const QVariantMap &values);
@@ -40,7 +45,7 @@ private:
QList<std::function<void()>> m_reload;
bool m_loading = false;
QVector<Sensor> m_sensors;
QComboBox *m_mode, *m_metric, *m_outside, *m_history;
QComboBox *m_mode, *m_metric, *m_secondMetric, *m_outside, *m_history;
QDoubleSpinBox *m_minimum, *m_maximum;
QFormLayout *m_scaleLayout;
QWidget *m_graphSettings;
+19 -8
View File
@@ -119,7 +119,8 @@ QGroupBox *Window::sensorGroup(const QString &title, Chart *chart, const QVector
QVector<Sensor> main, extra;
for (const auto &sensor : sensors) {
chart->addSeries(sensor.id, sensor.name, sensor.unit);
chart->setSelected(sensor.id, m_settings.value("series/" + sensor.id, !temperatures || sensor.primary).toBool());
const bool selectedByDefault = (temperatures || sensor.unit == "MHz") ? sensor.primary : true;
chart->setSelected(sensor.id, m_settings.value("series/" + sensor.id, selectedByDefault).toBool());
(temperatures && !sensor.primary ? extra : main).append(sensor);
}
auto makeLegend = [&](const QVector<Sensor> &entries) {
@@ -310,15 +311,24 @@ void Window::updateTray()
if (config.mode == "icon") tray.icon->setIcon(windowIcon());
else {
const double since = now - (config.mode == "graph" ? config.style.historyMs : 0);
const auto historyFor = [&](const Sensor &sensor) {
if (sensor.id == "cpu-usage") {
QVector<QPointF> history;
if (metric.id == "cpu-usage")
for (auto it = historyStart(m_usageHistory, since); it != m_usageHistory.cend(); ++it) history.append(*it);
else if (metric.id == "battery" || metric.id == "battery-rate") history = m_batteryChart->history(metric.id, since);
else if (metric.id.startsWith("power/")) history = m_powerChart->history(metric.id, since);
else history = (metric.unit == "MHz" ? m_frequencyChart : m_temperatureChart)->history(metric.id, since);
tray.icon->setIcon(telemetryIcon(config.mode == "graph", history, metric.unit, config.style, now));
return history;
}
tray.icon->setToolTip(trayTooltip(config.values, m_values, temperatures, m_batteryText, m_topAppText));
if (sensor.id == "battery" || sensor.id == "battery-rate") return m_batteryChart->history(sensor.id, since);
if (sensor.id.startsWith("power/")) return m_powerChart->history(sensor.id, since);
return (sensor.unit == "MHz" ? m_frequencyChart : m_temperatureChart)->history(sensor.id, since);
};
std::optional<TrayReading> second;
if (config.mode == "number" && config.secondMetric) {
const auto history = historyFor(*config.secondMetric);
second = TrayReading{history.isEmpty() ? NAN : history.last().y(), config.secondMetric->unit};
}
tray.icon->setIcon(telemetryIcon(config.mode == "graph", historyFor(metric), metric.unit, config.style, now, second));
}
tray.icon->setToolTip(trayTooltip(config.values, m_values, temperatures, m_batteryText, m_topAppText, m_powerMonitor.sensors()));
}
}
QVariantMap Window::controlValue(const QString &key) const
@@ -531,7 +541,8 @@ void Window::sample()
sampleSensors(m_frequencies, m_frequencyChart); sampleSensors(m_temperatures, m_temperatureChart);
const auto power = m_powerMonitor.sample(m_fastTimer.interval());
m_powerChart->sample(power, m_fastTimer.interval());
m_values.insert(power);
for (const auto &sensor : m_powerMonitor.sensors())
m_values[sensor.id] = power.value(sensor.id, NAN);
const QString ec = ecHwmon();
const auto rpm = readNumber(ec + "/fan1_input");
const auto mode = readNumber(ec + "/pwm1_enable");
@@ -15,6 +15,54 @@ class HardwareTest : public QObject {
QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write(value), value.size());
}
private Q_SLOTS:
void coreGroupFrequencies() {
QTemporaryDir root;
const QString base = root.path() + "/devices/system/cpu/cpufreq/";
for (int i = 0; i < 3; ++i) {
const auto policy = base + "policy" + QString::number(i) + "/";
put(policy + "related_cpus", QByteArray::number(i));
put(policy + "scaling_cur_freq", QByteArray::number((i + 1) * 1000000));
}
// No core-type metadata: retain the original combined reading.
auto sensors = frequencySensors(root.path());
QCOMPARE(sensors.size(), 1);
QCOMPARE(sensors[0].id, QString("cpu"));
QVERIFY(sensors[0].primary);
QCOMPARE(sensorValue(sensors[0]), std::optional<double>(2000));
put(root.path() + "/bus/event_source/devices/cpu_core/cpus", "2");
put(root.path() + "/bus/event_source/devices/cpu_atom/cpus", "0-1");
sensors = frequencySensors(root.path());
QCOMPARE(sensors.size(), 5);
QCOMPARE(sensors[0].id, QString("cpu/p"));
QCOMPARE(sensors[1].id, QString("cpu/e"));
QCOMPARE(sensors[2].id, QString("cpu"));
QVERIFY(sensors[0].primary && sensors[1].primary && !sensors[2].primary);
QCOMPARE(sensorValue(sensors[0]), std::optional<double>(3000));
QCOMPARE(sensorValue(sensors[1]), std::optional<double>(1500));
QCOMPARE(sensorValue(sensors[2]), std::optional<double>(2000));
QCOMPARE(sensors[3].id, QString("cpu/p/max"));
QCOMPARE(sensors[4].id, QString("cpu/e/max"));
QVERIFY(!sensors[3].primary && !sensors[4].primary);
QCOMPARE(sensorValue(sensors[3]), std::optional<double>(3000));
QCOMPARE(sensorValue(sensors[4]), std::optional<double>(2000));
put(base + "policy0/scaling_cur_freq", "4000000");
QCOMPARE(sensorValue(sensors[4]), std::optional<double>(4000));
put(base + "policy0/scaling_cur_freq", "1000000");
QCOMPARE(sensorValue(sensors[4]), std::optional<double>(2000)); // Not a historical peak.
// Missing readings aren't zeroes, and membership isn't guessed by speed.
put(base + "policy0/scaling_cur_freq", "unavailable");
QCOMPARE(sensorValue(sensors[1]), std::optional<double>(2000));
QCOMPARE(sensorValue(sensors[4]), std::optional<double>(2000));
put(base + "policy1/scaling_cur_freq", "unavailable");
QVERIFY(!sensorValue(sensors[1]));
QVERIFY(!sensorValue(sensors[4]));
QCOMPARE(sensorValue(sensors[2]), std::optional<double>(3000));
put(base + "policy2/related_cpus", "1 2");
sensors = frequencySensors(root.path());
QCOMPARE(sensors.size(), 1); // A policy spanning types cannot be split.
QVERIFY(sensors[0].primary);
}
void energyCounters() {
EnergyCounter counter;
const quint64 range = 262143328850ULL;
+115 -4
View File
@@ -21,6 +21,7 @@
#include <QSlider>
#include "graphdata.h"
#include "tooltip.h"
#include "readingformat.h"
#include "processusage.h"
#include <QFile>
#include <QDir>
@@ -33,6 +34,112 @@
class WindowTest : public QObject {
Q_OBJECT
private Q_SLOTS:
void readingFormat_data() {
QTest::addColumn<double>("value"); QTest::addColumn<QString>("text");
QTest::newRow("zero") << 0. << QString("0.0");
QTest::newRow("small") << 1.26 << QString("1.3");
QTest::newRow("negative") << -1.26 << QString("-1.3");
QTest::newRow("negative zero") << -0.01 << QString("0.0");
QTest::newRow("whole below ten") << 9. << QString("9.0");
QTest::newRow("round to boundary") << 9.96 << QString("10.0");
QTest::newRow("ten") << 10. << QString("10");
QTest::newRow("minus ten") << -10. << QString("-10");
QTest::newRow("round up") << 12.6 << QString("13");
QTest::newRow("round down") << 12.4 << QString("12");
QTest::newRow("negative round") << -12.5 << QString("-13");
QTest::newRow("unavailable") << double(NAN) << QString("");
QTest::newRow("infinite") << double(INFINITY) << QString("");
}
void readingFormat() {
QFETCH(double, value); QFETCH(QString, text);
QCOMPARE(formatReading(value), text);
}
void trayNumberPrecision() {
TrayStyle style; style.border = false;
style.backgroundColor = Qt::black; style.lineColor = Qt::white;
const auto render = [&](double value, const QString &unit) {
return telemetryIcon(false, {{0, value}}, unit, style, 0).pixmap(64, 64).toImage();
};
QCOMPARE(render(1250, "MHz"), render(1.25, "W")); // GHz in the icon.
QCOMPARE(render(1.25, "W"), render(1.25, "°C"));
QVERIFY(render(1.2, "W") != render(1.4, "W"));
QCOMPARE(render(12.6, "W"), render(13, "%"));
QCOMPARE(render(10000, "MHz"), render(10, "%"));
QVERIFY(render(-1.2, "W") != render(1.2, "W"));
}
void stackedTrayNumbers() {
TrayStyle style; style.border = false;
style.backgroundColor = Qt::black; style.lineColor = Qt::white;
const auto render = [&](double first, double second, const QString &unit = "W") {
return telemetryIcon(false, {{0, first}}, "W", style, 0, TrayReading{second, unit}).pixmap(64, 64).toImage();
};
const auto both = render(1.2, 3.4);
const auto changedTop = render(5.6, 3.4), changedBottom = render(1.2, 7.8);
QVERIFY(both.copy(0, 0, 64, 32) != changedTop.copy(0, 0, 64, 32));
QCOMPARE(both.copy(0, 32, 64, 32), changedTop.copy(0, 32, 64, 32));
QCOMPARE(both.copy(0, 0, 64, 32), changedBottom.copy(0, 0, 64, 32));
QVERIFY(both.copy(0, 32, 64, 32) != changedBottom.copy(0, 32, 64, 32));
QCOMPARE(both, render(1.2, 3400, "MHz"));
QVERIFY(both != render(1.2, NAN)); // Missing is a dash, not removal of the second row.
// Both rows use their available height with small margins.
for (int row = 0; row < 2; ++row) {
int first = 64, last = -1;
for (int y = row * 32; y < (row + 1) * 32; ++y)
for (int x = 0; x < 64; ++x)
if (qRed(both.pixel(x, y)) > 128) { first = std::min(first, y); last = std::max(last, y); }
QVERIFY(first <= row * 32 + 5); QVERIFY(last >= row * 32 + 26);
}
const auto single = telemetryIcon(false, {{0, 1.2}}, "W", style, 0).pixmap(64, 64).toImage();
QCOMPARE(single, telemetryIcon(false, {{0, 1.2}}, "W", style, 0, std::nullopt).pixmap(64, 64).toImage());
const QVector<QPointF> history{{0, 1.2}, {1000, 3.4}};
QCOMPARE(telemetryIcon(true, history, "W", style, 1000).pixmap(64, 64).toImage(),
telemetryIcon(true, history, "W", style, 1000, TrayReading{90, "%"}).pixmap(64, 64).toImage());
}
void secondTrayReading() {
const QVector<Sensor> sensors{{"cpu-usage", "CPU usage", {}, "%"}, {"cpu/p/max", "P-cores maximum", {}, "MHz"}};
TrayPage page(QVariantMap{{"tray/mode", "number"}}, sensors);
auto *second = page.findChild<QComboBox *>("traySecondMetric");
auto *mode = page.findChild<QComboBox *>("trayDisplayMode");
QVERIFY(second); QCOMPARE(second->currentText(), QString("None")); QVERIFY(!page.secondMetric());
QVERIFY(!second->isHidden());
const auto original = page.draft();
second->setCurrentIndex(second->findData("cpu/p/max"));
QVERIFY(page.secondMetric()); QCOMPARE(page.secondMetric()->id, QString("cpu/p/max"));
const auto chosen = page.draft();
TrayPage restored(chosen, sensors);
QCOMPARE(restored.secondMetric()->id, QString("cpu/p/max"));
mode->setCurrentIndex(mode->findData("graph")); QVERIFY(second->isHidden());
mode->setCurrentIndex(mode->findData("number")); QVERIFY(!second->isHidden());
QCOMPARE(page.secondMetric()->id, QString("cpu/p/max"));
page.load(original); QVERIFY(!page.secondMetric());
page.load(chosen); QCOMPARE(page.secondMetric()->id, QString("cpu/p/max"));
TrayPage missing(chosen, {sensors[0]}); QVERIFY(!missing.secondMetric());
}
void powerHoverOptions() {
const QVector<Sensor> sensors{{"power/rapl/0", "CPU package", {}, "W"},
{"power/rapl/0:0", "CPU cores", {}, "W"}, {"battery-rate", "Battery rate", {}, "W"}};
QVariantMap settings{{"tray/hover/cpu", false}, {"tray/hover/fan", false}, {"tray/hover/battery", false}};
const QVector<Sensor> power{sensors[0], sensors[1]};
const QMap<QString, double> values{{power[0].id, 4.25}, {power[1].id, 12.6}};
TrayPage page(settings, sensors);
auto *package = page.findChild<QCheckBox *>("hover/" + power[0].id);
auto *cores = page.findChild<QCheckBox *>("hover/" + power[1].id);
QVERIFY(package); QVERIFY(cores);
QVERIFY(!package->isChecked()); QVERIFY(!cores->isChecked());
QVERIFY(!page.findChild<QCheckBox *>("hover/battery-rate"));
QVERIFY(trayTooltip(page.draft(), values, {}, {}, {}, power).isEmpty());
const auto saved = page.draft();
package->setChecked(true);
QCOMPARE(trayTooltip(page.draft(), values, {}, {}, {}, power), QString("CPU package: 4.3 W"));
cores->setChecked(true);
QCOMPARE(trayTooltip(page.draft(), values, {}, {}, {}, power), QString("Power consumption:\n\u2003CPU package: 4.3 W\n\u2003CPU cores: 13 W"));
QCOMPARE(trayTooltip(page.draft(), {}, {}, {}, {}, power), QString("Power consumption:\n\u2003CPU package: — W\n\u2003CPU cores: — W"));
const auto chosen = page.draft();
page.load(saved); QVERIFY(!package->isChecked()); QVERIFY(!cores->isChecked());
page.load(chosen); QVERIFY(package->isChecked()); QVERIFY(cores->isChecked());
TrayPage restored(chosen, sensors);
QCOMPARE(trayTooltip(restored.draft(), values, {}, {}, {}, power), trayTooltip(chosen, values, {}, {}, {}, power));
}
void hoverFormatting() {
const QVector<Sensor> sensors{{"cros_ec/peci-temp", "CPU", {}, "°C"}, {"spd5118/temp1", "Memory", {}, "°C"},
{"nvme/Composite", "NVMe", {}, "°C"}};
@@ -52,14 +159,15 @@ private Q_SLOTS:
settings["tray/hover/temperature/memory"] = false; settings["tray/hover/temperature/nvme"] = false;
QCOMPARE(trayTooltip(settings, readings, temperatures, {}, apps), QString("CPU 47°C"));
QCOMPARE(trayTooltip(settings, {}, temperatures, {}, {}), QString("CPU —°C"));
QCOMPARE(trayTooltip(settings, {{sensors[0].id, 3.2}}, temperatures, {}, {}), QString("CPU 3.2°C"));
settings["tray/hover/temperature/cpu"] = false;
QVERIFY(trayTooltip(settings, readings, temperatures, {}, {}).isEmpty());
QVariantMap battery{{"state", "Discharging"}, {"capacity", 75}, {"watts", 12.5}, {"remainingMWh", 56250}, {"fullMWh", 75000}};
QCOMPARE(batteryTooltip(battery, 80, {{"TimeToEmpty", 5400}}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003-12.5 W · 1 h 30 min to 0%"));
QCOMPARE(batteryTooltip(battery, 80, {{"TimeToEmpty", 5400}}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003-13 W · 1 h 30 min to 0%"));
battery["state"] = "Charging";
QCOMPARE(batteryTooltip(battery, 100, {{"TimeToFull", 3600}}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003+12.5 W · 1 h 0 min to 100%"));
QCOMPARE(batteryTooltip(battery, 100, {{"TimeToFull", 3600}}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003+13 W · 1 h 0 min to 100%"));
battery["charge_full"] = 10000; battery["charge_now"] = 7500; battery["current_now"] = 1000;
QCOMPARE(batteryTooltip(battery, 80, {}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003+12.5 W · ≈0 h 30 min to 80%"));
QCOMPARE(batteryTooltip(battery, 80, {}), QString("Battery 75% · 56 250 mWh / 75 000 mWh\n\u2003+13 W · ≈0 h 30 min to 80%"));
battery["current_now"] = 0;
QVERIFY(batteryTooltip(battery, 80, {}).contains("Time remaining unavailable"));
battery["capacity"] = 80;
@@ -296,6 +404,7 @@ private Q_SLOTS:
first->findChild<QComboBox *>("trayHistory")->setCurrentIndex(0);
first->findChild<QCheckBox *>("border")->setChecked(false);
last->findChild<QComboBox *>("trayDisplayMode")->setCurrentIndex(2);
last->findChild<QComboBox *>("traySecondMetric")->setCurrentIndex(1);
last->findChild<QSpinBox *>("hover/topApps")->setValue(3);
const auto firstValues = first->draft(), lastValues = last->draft();
const auto original = page.draft();
@@ -304,6 +413,8 @@ private Q_SLOTS:
QSignalSpy changed(&page, &TrayIconsPage::settingsChanged);
cards[2]->findChild<QToolButton *>("moveIconLeft")->click();
QCOMPARE(page.configurations()[1].values, lastValues); QCOMPARE(changed.count(), 1);
QVERIFY(page.configurations()[1].secondMetric);
QCOMPARE(page.configurations()[1].secondMetric->id, QString("cpu-usage"));
cards[0]->findChild<QToolButton *>("moveIconRight")->click();
QCOMPARE(page.configurations()[0].values, lastValues);
QCOMPARE(page.configurations()[1].values, firstValues);
@@ -411,7 +522,7 @@ private Q_SLOTS:
for (int i = 0; i <= 7200; ++i) chart.sample({{"hidden", double(i)}}, 500, start + i * 500);
QCOMPARE(chart.history("hidden").size(), 7201); // Not capped at 600, even when hidden.
chart.setSelected("hidden", true);
QVERIFY(chart.readingAt(start + 500).contains("hidden: 1 MHz"));
QVERIFY(chart.readingAt(start + 500).contains("hidden: 1.0 MHz"));
const auto end = double(start + 3600000);
chart.setHistoryWindow(historyRetentionMs, true);
QCOMPARE(chart.timeRange(), qMakePair(double(start), end));
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: MIT
import QtQml
Timer {
id: retry
required property QtObject auth
required property int fingerprintType
property bool enabled: true
readonly property bool fingerprintActive: (auth.authenticatorTypes & fingerprintType) !== 0
property bool sawInfo: false
property bool sawError: false
interval: 6000
function reset() {
stop();
sawInfo = false;
sawError = false;
}
onEnabledChanged: {
if (!enabled) {
reset();
}
}
onFingerprintActiveChanged: {
if (fingerprintActive) {
reset();
} else if (enabled && sawInfo && !sawError) {
// An idle conversation ended. Consume the retry until another
// conversation actually supplies fingerprint instructions.
sawInfo = false;
restart();
}
}
onTriggered: {
if (enabled && !fingerprintActive) {
auth.startAuthenticating();
}
}
property Connections authenticationSignals: Connections {
target: retry.auth
function onNoninteractiveInfo(kind, source) {
if (retry.enabled && (kind & retry.fingerprintType)) {
retry.sawInfo = true;
}
}
function onNoninteractiveError(kind, source) {
if (kind & retry.fingerprintType) {
// Do not automatically replenish failed scan attempts.
retry.sawError = true;
retry.stop();
}
}
function onFailed(kind, source) {
if (kind === 0) {
retry.reset();
}
}
function onSucceeded() {
retry.reset();
}
}
}
+10 -4
View File
@@ -57,7 +57,7 @@
onUiVisibleChanged: {
if (uiVisible) {
Window.window.requestActivate();
@@ -135,6 +143,18 @@
@@ -135,6 +143,24 @@
}
authenticator.startAuthenticating();
}
@@ -72,11 +72,17 @@
+ interval: 6000
+ running: root.viewVisible
+ onTriggered: authenticator.startAuthenticating()
+ }
+ FedoraToolsFingerprintRetry {
+ auth: authenticator
+ fingerprintType: ScreenLocker.Authenticator.Fingerprint
+ enabled: root.viewVisible && !authenticator.unlocked
+ && !graceLockTimer.running && !resumeAuthenticationTimer.running
+ }
onBlockUIChanged: {
if (blockUI) {
fadeoutTimer.running = false;
@@ -194,6 +214,6 @@
@@ -194,6 +220,6 @@
WallpaperFader {
anchors.fill: parent
- state: lockScreenRoot.uiVisible ? "on" : "off"
@@ -84,7 +90,7 @@
source: wallpaper
mainStack: mainStack
footer: footer
@@ -211,7 +231,7 @@
@@ -211,7 +237,7 @@
samples: 15
spread: 0.2
color : Qt.rgba(0, 0, 0, 0.7)
@@ -93,6 +99,6 @@
Behavior on opacity {
OpacityAnimator {
duration: Kirigami.Units.veryLongDuration * 2
@@ -265 +285 @@
@@ -265 +291 @@
- lockScreenUiVisible: lockScreenRoot.uiVisible
+ lockScreenUiVisible: true
@@ -1,6 +1,6 @@
Name: plasma-always-show-unlock
Version: 0.1.0
Release: 10%{?dist}
Release: 11%{?dist}
Summary: Immediately show the Plasma unlock prompt without requiring input
License: MIT AND GPL-2.0-or-later
@@ -11,10 +11,13 @@ Source2: test-plasma-always-show-unlock
Source3: LockScreenUi.qml
Source4: LICENSE
Source5: README.md
Source6: FedoraToolsFingerprintRetry.qml
Source7: tst_fingerprintretry.qml
BuildArch: noarch
BuildRequires: bash
BuildRequires: patch
BuildRequires: qt6-qtdeclarative-devel
Requires: bash
Requires: patch
Requires: plasma-desktop >= 6.7.4
@@ -31,6 +34,11 @@ patch after plasma-desktop updates and leaves unknown future layouts unchanged.
%check
bash %{SOURCE2} %{SOURCE0} %{SOURCE1} %{SOURCE3}
mkdir -p qml-tests/tests
cp %{SOURCE6} qml-tests/
cp %{SOURCE7} qml-tests/tests/
QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \
qmltestrunner-qt6 -input qml-tests/tests
%install
install -Dpm 0755 %{SOURCE0} \
@@ -41,6 +49,15 @@ install -Dpm 0644 %{SOURCE4} \
%{buildroot}%{_licensedir}/%{name}/LICENSE
install -Dpm 0644 %{SOURCE5} \
%{buildroot}%{_docdir}/%{name}/README.md
install -Dpm 0644 %{SOURCE6} \
%{buildroot}%{_datadir}/plasma/shells/org.kde.plasma.desktop/contents/lockscreen/FedoraToolsFingerprintRetry.qml
%pre
# Revert using the installed helper and patch, before RPM replaces either.
# The new patch must be applied to the original QML, not an older patched copy.
if [ -x %{_libexecdir}/plasma-always-show-unlock ]; then
%{_libexecdir}/plasma-always-show-unlock revert || exit 1
fi
%preun
if [ "$1" -eq 0 ]; then
@@ -55,8 +72,14 @@ fi
%doc %{_docdir}/%{name}/README.md
%{_libexecdir}/plasma-always-show-unlock
%{_datadir}/plasma-always-show-unlock/LockScreenUi.patch
%{_datadir}/plasma/shells/org.kde.plasma.desktop/contents/lockscreen/FedoraToolsFingerprintRetry.qml
%changelog
* Mon Sep 14 2026 fedora-tools contributors - 0.1.0-11
- Retry idle fingerprint conversations without polling or restarting fprintd
- Preserve failed-scan handling and test timeout recovery in QML
- Revert the installed patch before upgrading to a new revision
* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-10
- Clarify the package summary
@@ -37,6 +37,8 @@ run_tool status >/dev/null
grep -q 'function onViewVisibleChanged()' "$target"
grep -q 'id: resumeAuthenticationTimer' "$target"
grep -q 'id: postGraceAuthenticationTimer' "$target"
grep -q 'FedoraToolsFingerprintRetry {' "$target"
grep -q 'fingerprintType: ScreenLocker.Authenticator.Fingerprint' "$target"
grep -q 'interval: 1500' "$target"
grep -q 'interval: 6000' "$target"
grep -q 'running: root.viewVisible' "$target"
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: MIT
import QtQml
import QtTest
import ".."
TestCase {
id: testCase
name: "FingerprintRetry"
property alias authenticator: auth
QtObject {
id: auth
property int authenticatorTypes: 0
property int starts: 0
signal noninteractiveInfo(int kind, QtObject source)
signal noninteractiveError(int kind, QtObject source)
signal failed(int kind, QtObject source)
signal succeeded()
function startAuthenticating() { ++starts; }
}
FedoraToolsFingerprintRetry {
id: retry
auth: testCase.authenticator
fingerprintType: 1
}
function initTestCase() {
compare(retry.interval, 6000);
compare(retry.repeat, false);
}
function init() {
retry.enabled = false;
auth.authenticatorTypes = 0;
auth.starts = 0;
retry.interval = 20;
retry.enabled = true;
}
function beginFingerprint() {
auth.authenticatorTypes = 1;
auth.noninteractiveInfo(1, auth);
}
function test_idleTimeoutRetriesWithoutInput() {
beginFingerprint();
wait(50);
compare(auth.starts, 0); // Never restart a working conversation.
auth.authenticatorTypes = 0;
verify(retry.running);
tryCompare(auth, "starts", 1);
wait(60);
compare(auth.starts, 1); // No polling while the reader stays unavailable.
beginFingerprint();
auth.authenticatorTypes = 0;
tryCompare(auth, "starts", 2); // Later idle timeouts can also recover.
}
function test_noReaderOrNoInstructions() {
wait(60);
compare(auth.starts, 0);
auth.authenticatorTypes = 1;
auth.authenticatorTypes = 0;
wait(60);
compare(auth.starts, 0);
}
function test_failedScansDoNotGetAutomaticNewAttempts() {
beginFingerprint();
auth.noninteractiveError(1, auth);
auth.noninteractiveInfo(1, auth); // Another instruction or timeout message.
auth.failed(1, auth);
auth.authenticatorTypes = 0;
wait(60);
compare(auth.starts, 0);
}
function test_unavailableRetryDoesNotLoop() {
beginFingerprint();
auth.authenticatorTypes = 0;
tryCompare(auth, "starts", 1);
auth.authenticatorTypes = 1; // PAM marks active before opening the reader.
auth.failed(1, auth);
auth.authenticatorTypes = 0;
wait(60);
compare(auth.starts, 1);
}
function test_smartcardDoesNotArmOrCancelFingerprintRetry() {
auth.authenticatorTypes = 2;
auth.noninteractiveInfo(2, auth);
auth.authenticatorTypes = 0;
wait(60);
compare(auth.starts, 0);
beginFingerprint();
auth.noninteractiveError(2, auth);
auth.authenticatorTypes = 2;
tryCompare(auth, "starts", 1);
}
function test_cancelPendingRetry_data() {
return [ {tag: "manual restart"}, {tag: "success"},
{tag: "password failure"}, {tag: "hidden or suspended"} ];
}
function test_cancelPendingRetry(data) {
beginFingerprint();
auth.authenticatorTypes = 0;
verify(retry.running);
switch (data.tag) {
case "manual restart": auth.authenticatorTypes = 1; break;
case "success": auth.succeeded(); break;
case "password failure": auth.failed(0, auth); break;
case "hidden or suspended": retry.enabled = false; break;
}
wait(60);
compare(auth.starts, 0);
}
function test_passwordFailureBeforeAvailabilityChange() {
beginFingerprint();
auth.failed(0, auth);
auth.authenticatorTypes = 0;
wait(60);
compare(auth.starts, 0);
}
}
@@ -10,7 +10,7 @@ endif()
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Test)
find_package(Qt6 REQUIRED COMPONENTS Core Qml Test)
find_package(ECM REQUIRED NO_MODULE)
list(APPEND CMAKE_MODULE_PATH "${ECM_MODULE_PATH}")
include(ECMQtDeclareLoggingCategory)
@@ -25,8 +25,9 @@ ecm_qt_declare_logging_category(sources
add_executable(retrytest ${sources})
target_include_directories(retrytest PRIVATE
${CMAKE_CURRENT_BINARY_DIR} ${KSCREENLOCKER_SOURCE}/greeter)
target_compile_definitions(retrytest PRIVATE HAVE_PAM_FAIL_DELAY)
target_link_libraries(retrytest PRIVATE Qt6::Core Qt6::Test)
target_compile_definitions(retrytest PRIVATE HAVE_PAM_FAIL_DELAY
RETRY_QML="${CMAKE_CURRENT_SOURCE_DIR}/../../../plasma-always-show-unlock/FedoraToolsFingerprintRetry.qml")
target_link_libraries(retrytest PRIVATE Qt6::Core Qt6::Qml Qt6::Test)
enable_testing()
add_test(NAME fingerprint-retry COMMAND retrytest)
set_tests_properties(fingerprint-retry PROPERTIES TIMEOUT 30)
@@ -2,6 +2,8 @@
#include <QSignalSpy>
#include <QTest>
#include <QQmlComponent>
#include <QQmlEngine>
#include <security/pam_appl.h>
#include <cstdlib>
#include <cstring>
@@ -38,9 +40,21 @@ int pam_authenticate(pam_handle_t *handle, int)
if (handle->service == "transient" && handle->attempts == 1) {
return PAM_AUTHINFO_UNAVAIL;
}
if (handle->service == "timeout" && handle->attempts == 1) {
if (handle->service == "attempt-limit" && handle->attempts == 1) {
return PAM_MAXTRIES;
}
if (handle->service == "idle" && handle->attempts == 1) {
pam_message message{PAM_TEXT_INFO, "Test fingerprint instruction"};
const pam_message *messages = &message;
pam_response *response = nullptr;
const auto &conversation = handle->conversation;
const int result = conversation.conv(1, &messages, &response, conversation.appdata_ptr);
if (response) {
std::free(response->resp);
std::free(response);
}
return result == PAM_SUCCESS ? PAM_AUTHINFO_UNAVAIL : result;
}
pam_message message{PAM_PROMPT_ECHO_OFF, "Test credential:"};
const pam_message *messages = &message;
pam_response *response = nullptr;
@@ -61,11 +75,60 @@ class RetryTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void idleFingerprintRetriesThroughQml_data()
{
QTest::addColumn<bool>("automaticRetry");
QTest::newRow("automatic retry") << true;
QTest::newRow("without the new retry") << false;
}
void idleFingerprintRetriesThroughQml()
{
QFETCH(bool, automaticRetry);
qmlRegisterUncreatableType<PamAuthenticator>("org.kde.kscreenlocker", 1, 0, "Authenticator", "Test instance only");
auto password = std::make_unique<PamAuthenticator>(QStringLiteral("password"), QStringLiteral("test"));
auto fingerprint = std::make_unique<PamAuthenticator>(QStringLiteral("idle"), QStringLiteral("test"), PamAuthenticator::Fingerprint);
QSignalSpy passwordPrompts(password.get(), &PamAuthenticator::promptForSecret);
QSignalSpy fingerprintPrompts(fingerprint.get(), &PamAuthenticator::promptForSecret);
std::vector<std::unique_ptr<PamAuthenticator>> others;
others.push_back(std::move(fingerprint));
PamAuthenticators authenticators(std::move(password), std::move(others));
QSignalSpy successes(&authenticators, &PamAuthenticators::succeeded);
QQmlEngine engine;
QQmlComponent component(&engine, QUrl::fromLocalFile(QStringLiteral(RETRY_QML)));
QVERIFY2(component.isReady(), qPrintable(component.errorString()));
std::unique_ptr<QObject> retry(component.createWithInitialProperties({
{QStringLiteral("auth"), QVariant::fromValue(&authenticators)},
{QStringLiteral("fingerprintType"), int(PamAuthenticator::Fingerprint)},
{QStringLiteral("interval"), 50},
{QStringLiteral("enabled"), automaticRetry},
}));
QVERIFY2(retry, qPrintable(component.errorString()));
authenticators.startAuthenticating();
QTRY_COMPARE(passwordPrompts.count(), 1);
if (!automaticRetry) {
QTest::qWait(200);
QCOMPARE(fingerprintPrompts.count(), 0);
// Reproduce today's recovery via Ctrl without any daemon restart.
authenticators.startAuthenticating();
}
// With the helper enabled, no simulated input or manual start is needed.
QTRY_COMPARE_WITH_TIMEOUT(fingerprintPrompts.count(), 1, 2000);
QCOMPARE(passwordPrompts.count(), 1);
QVERIFY(!authenticators.isUnlocked());
QCOMPARE(successes.count(), 0);
authenticators.respond("correct");
QTRY_COMPARE(successes.count(), 1);
QVERIFY(authenticators.isUnlocked());
}
void retryWithActivePassword_data()
{
QTest::addColumn<QString>("service");
QTest::newRow("temporary unavailability") << QStringLiteral("transient");
QTest::newRow("attempt timeout") << QStringLiteral("timeout");
QTest::newRow("exhausted attempts, manual retry") << QStringLiteral("attempt-limit");
}
void retryWithActivePassword()
@@ -21,6 +21,8 @@ install -m 0644 \
"$topdir/SOURCES/LockScreenUi.qml"
install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
install -m 0644 "$repo_root/README.md" "$topdir/SOURCES/README.md"
install -m 0644 "$tool_dir/FedoraToolsFingerprintRetry.qml" "$topdir/SOURCES/FedoraToolsFingerprintRetry.qml"
install -m 0644 "$tool_dir/tests/tst_fingerprintretry.qml" "$topdir/SOURCES/tst_fingerprintretry.qml"
rpmbuild \
--define "_topdir $topdir" \
+2
View File
@@ -18,6 +18,8 @@ bash touchpad-hold-tap/tests/test-touchpad-hold-tap-config "$plugin" touchpad-ho
bash plasma-always-show-unlock/tests/test-plasma-always-show-unlock \
plasma-always-show-unlock/plasma-always-show-unlock \
plasma-always-show-unlock/LockScreenUi.patch plasma-always-show-unlock/tests/LockScreenUi.qml
QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \
qmltestrunner-qt6 -input plasma-always-show-unlock/tests
bash plasma-fingerprint-workaround/tests/test-controller \
plasma-fingerprint-workaround/plasma-fingerprint-workaround plasma-fingerprint-workaround/payload.conf