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 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 it after `plasma-desktop` updates. An unknown future layout is left unchanged
instead of being modified speculatively. Removing the package restores Plasma's instead of being modified speculatively. Removing the package restores Plasma's
original behaviour. It retries lock-screen authentication after resume and the original behaviour. It retries lock-screen authentication after resume, the
automatic-lock grace period, but does not modify `kscreenlocker` authentication automatic-lock grace period and idle fingerprint timeouts. Failed scans do not
code or `fprintd`. 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 ### 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. earlier operations may already have applied; remaining edits stay pending.
CPU slider bounds are read from Linux's hardware-frequency limits. 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. The battery heading shows full capacity, health and cycle count when available.
Capacity calculated from charge capacity and nominal voltage is approximate. Capacity calculated from charge capacity and nominal voltage is approximate.
Temperature labels distinguish CPU-area/board sensors from die readings; Temperature labels distinguish CPU-area/board sensors from die readings;
raw sysfs paths remain available in tooltips. CPU frequency is Linux's reported 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. 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, 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 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. 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. 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 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 an application icon, a history graph, or a number. Number mode offers an optional
window. New icons default to the application icon; your existing configuration 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; 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 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 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 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 target; fan RPM and duty percentage; and the main CPU, memory, NVMe, battery and
board temperatures. Multiple temperatures have a heading and indented lines. 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 Missing sensors are unavailable in the settings page. Batteries reporting only
charge are converted to mWh using nominal voltage. charge are converted to mWh using nominal voltage.
Choose zero to three top CPU applications, displayed below CPU usage in descending Choose zero to three top CPU applications, displayed below CPU usage in descending
@@ -1,6 +1,6 @@
Name: framework-laptop-tools Name: framework-laptop-tools
Version: 0.1.0 Version: 0.1.0
Release: 25%{?dist} Release: 28%{?dist}
Summary: Hardware controls and monitoring for Framework Laptop 13 Pro Summary: Hardware controls and monitoring for Framework Laptop 13 Pro
License: MIT License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools 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-fan-resume.service
%{_unitdir}/framework-laptop-tools-cpu.service %{_unitdir}/framework-laptop-tools-cpu.service
%changelog %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 * Sat Sep 12 2026 fedora-tools contributors - 0.1.0-25
- Add multiple tray icons, opacity controls and component power monitoring - Add multiple tray icons, opacity controls and component power monitoring
- Refine graph columns, live hover readings and sampling-interval changes - Refine graph columns, live hover readings and sampling-interval changes
+3 -1
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
#include "chart.h" #include "chart.h"
#include "graphdata.h" #include "graphdata.h"
#include "readingformat.h"
#include <QPainter> #include <QPainter>
#include <QMouseEvent> #include <QMouseEvent>
#include <QToolTip> #include <QToolTip>
@@ -230,7 +231,8 @@ QString Chart::readingAt(qint64 time) const
if (next != points.cbegin()) interval = std::max(interval, intervalAt((next - 1)->x())); 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 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))); && 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'); return lines.join('\n');
} }
+34 -10
View File
@@ -72,7 +72,26 @@ QVector<Sensor> temperatureSensors(const QString &sys)
QVector<Sensor> frequencySensors(const QString &sys) QVector<Sensor> frequencySensors(const QString &sys)
{ {
QVector<Sensor> sensors; 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"); const QDir drm(sys + "/class/drm");
for (const auto &card : drm.entryList({"card*"}, QDir::Dirs | QDir::NoDotAndDotDot)) { for (const auto &card : drm.entryList({"card*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
QDirIterator files(drm.filePath(card + "/device"), {"act_freq"}, QDir::Files, QDirIterator files(drm.filePath(card + "/device"), {"act_freq"}, QDir::Files,
@@ -80,22 +99,27 @@ QVector<Sensor> frequencySensors(const QString &sys)
while (files.hasNext()) { while (files.hasNext()) {
const QString path = files.next(); const QString path = files.next();
const QString relative = path.mid(drm.filePath(card + "/device/").size()); 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; return sensors;
} }
std::optional<double> sensorValue(const Sensor &sensor) std::optional<double> sensorValue(const Sensor &sensor)
{ {
if (sensor.id == "cpu") { if (sensor.id == "cpu" || !sensor.frequencyPaths.isEmpty()) {
const QDir dir(sensor.path); QStringList paths = sensor.frequencyPaths;
double total = 0; if (sensor.id == "cpu") {
int count = 0; const QDir dir(sensor.path);
for (const auto &policy : dir.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot)) { for (const auto &policy : dir.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot))
const auto n = readNumber(dir.filePath(policy + "/scaling_cur_freq")); paths.append(dir.filePath(policy + "/scaling_cur_freq"));
if (n) { total += *n; ++count; }
} }
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") { if (sensor.unit == "°C") {
const QString fault = sensor.path.chopped(6) + "_fault"; const QString fault = sensor.path.chopped(6) + "_fault";
+3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
#pragma once #pragma once
#include <QString> #include <QString>
#include <QStringList>
#include <QVariantMap> #include <QVariantMap>
#include <QVector> #include <QVector>
#include <optional> #include <optional>
@@ -9,6 +10,8 @@ struct Sensor {
QString id, name, path, unit; QString id, name, path, unit;
double scale = 1; double scale = 1;
bool primary = false; bool primary = false;
QStringList frequencyPaths;
bool maximum = false;
}; };
QString readText(const QString &path); QString readText(const QString &path);
std::optional<double> readNumber(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 // SPDX-License-Identifier: MIT
#include "tooltip.h" #include "tooltip.h"
#include "readingformat.h"
#include <cmath> #include <cmath>
#include <algorithm> #include <algorithm>
@@ -15,9 +16,9 @@ QVector<TooltipSensor> tooltipSensors(const QVector<Sensor> &sensors)
} }
return result; 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) 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"; text += " · " + energy("remainingMWh") + " mWh / " + energy("fullMWh") + " mWh";
const auto watts = batteryRate(battery); 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"; const bool charging = battery["state"] == "Charging", discharging = battery["state"] == "Discharging";
double seconds = 0; double seconds = 0;
bool approximate = false; bool approximate = false;
@@ -60,7 +61,8 @@ QString batteryTooltip(const QVariantMap &battery, int limit, const QVariantMap
return text; return text;
} }
QString trayTooltip(const QVariantMap &settings, const QMap<QString, double> &values, 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 reading = [&](const QString &id, int decimals) {
const auto it = values.constFind(id); 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, "battery")) lines << battery;
if (tooltipEnabled(settings, "fan")) lines << "Fan speed: " + reading("fan", 0) + " RPM (" + reading("fan-duty", 0) + " %)"; 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; QStringList temps;
for (const auto &sensor : temperatures) for (const auto &sensor : temperatures)
if (!sensor.id.isEmpty() && tooltipEnabled(settings, "temperature/" + sensor.key)) 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:"; if (temps.size() > 1) lines << "Temperatures:";
for (const auto &temp : temps) lines << (temps.size() > 1 ? "\u2003" : "") + temp; for (const auto &temp : temps) lines << (temps.size() > 1 ? "\u2003" : "") + temp;
return lines.join('\n'); return lines.join('\n');
+3 -2
View File
@@ -4,8 +4,9 @@
struct TooltipSensor { QString key, name, id; }; struct TooltipSensor { QString key, name, id; };
QVector<TooltipSensor> tooltipSensors(const QVector<Sensor> &sensors); 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); int tooltipAppCount(const QVariantMap &settings);
QString batteryTooltip(const QVariantMap &battery, int limit, const QVariantMap &upower); QString batteryTooltip(const QVariantMap &battery, int limit, const QVariantMap &upower);
QString trayTooltip(const QVariantMap &settings, const QMap<QString, double> &values, 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 = {});
+25 -6
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
#include "tray.h" #include "tray.h"
#include "graphdata.h" #include "graphdata.h"
#include "readingformat.h"
#include <QPainter> #include <QPainter>
#include <cmath> #include <cmath>
#include <algorithm> #include <algorithm>
@@ -30,7 +31,8 @@ QRectF trayPlotRect(bool border)
// Two-pixel border; reserve space for the graph's vertical padding inside it. // 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); 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); QPixmap pixmap(64, 64); pixmap.fill(style.backgroundColor);
QPainter p(&pixmap); p.setRenderHint(QPainter::Antialiasing); QPainter p(&pixmap); p.setRenderHint(QPainter::Antialiasing);
@@ -67,11 +69,28 @@ QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &u
} }
p.restore(); p.restore();
} else { } else {
const QString value = !std::isfinite(latest) ? "" : unit == "MHz" const bool stacked = !graph && second.has_value();
? QString::number(latest / 1000, 'f', 1) : QString::number(std::round(latest), 'f', 0); const auto drawNumber = [&](const TrayReading &reading, const QRectF &rect) {
QFont font; font.setBold(true); font.setPixelSize(42); const bool adaptive = reading.unit == "MHz" || reading.unit == "W" || reading.unit == "°C";
while (QFontMetrics(font).horizontalAdvance(value) > area.width() && font.pixelSize() > 12) font.setPixelSize(font.pixelSize() - 1); const QString value = !std::isfinite(reading.value) ? "" : adaptive
p.setFont(font); p.drawText(area, Qt::AlignCenter, value); ? 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 (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); return QIcon(pixmap);
} }
+5 -1
View File
@@ -23,5 +23,9 @@ struct TrayStyle {
qint64 historyMs = 60000; qint64 historyMs = 60000;
}; };
QRectF trayPlotRect(bool border); QRectF trayPlotRect(bool border);
struct TrayReading {
double value;
QString unit;
};
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style, 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); auto *graphForm = new QFormLayout(graphOptions); graphForm->setContentsMargins(24, 0, 0, 0);
graphForm->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); graphForm->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
form->addRow(graphOptions); form->addRow("Reading:", m_metric); 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); auto *appearance = new QWidget; auto *colors = new QFormLayout(appearance); colors->setContentsMargins(0, 0, 0, 0);
colors->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); colors->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
auto colorRow = [&](QFormLayout *target, const QString &label, const QString &key, const QColor &fallback, bool alpha = false) { 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(); m_values.insert("tray/outside", m_outside->currentData()); syncOutside(); if (!m_loading) Q_EMIT settingsChanged();
}); });
syncOutside(); layout->addWidget(m_graphSettings); syncOutside(); layout->addWidget(m_graphSettings);
auto syncMode = [this, appearance, graphOptions] { auto syncMode = [this, form, appearance, graphOptions] {
const bool telemetry = mode() != "icon"; const bool telemetry = mode() != "icon";
m_metric->setEnabled(telemetry); appearance->setVisible(telemetry); m_graphSettings->setVisible(mode() == "graph"); m_metric->setEnabled(telemetry); appearance->setVisible(telemetry); m_graphSettings->setVisible(mode() == "graph");
graphOptions->setVisible(mode() == "graph"); graphOptions->setVisible(mode() == "graph");
form->setRowVisible(m_secondMetric, mode() == "number");
}; };
connect(m_mode, &QComboBox::currentIndexChanged, this, [this, syncMode] { connect(m_mode, &QComboBox::currentIndexChanged, this, [this, syncMode] {
m_values.insert("tray/mode", m_mode->currentData()); syncMode(); if (!m_loading) Q_EMIT settingsChanged(); 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] { connect(m_metric, &QComboBox::currentIndexChanged, this, [this] {
m_values.insert("tray/metric", m_metric->currentData()); loadScale(); if (!m_loading) Q_EMIT settingsChanged(); 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) { 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(); 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(); loadScale(); syncMode();
m_values.insert("tray/mode", m_mode->currentData()); m_values.insert("tray/mode", m_mode->currentData());
m_values.insert("tray/metric", m_metric->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_values.insert("tray/outside", m_outside->currentData());
m_reload.append([this, syncMode, syncOutside] { m_reload.append([this, syncMode, syncOutside] {
m_mode->setCurrentIndex(std::max(0, m_mode->findData(m_values.value("tray/mode", "icon")))); 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_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")))); m_outside->setCurrentIndex(std::max(0, m_outside->findData(m_values.value("tray/outside", "clamp"))));
syncMode(); syncOutside(); 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); appOptions->setEnabled(cpu->isChecked()); connect(cpu, &QCheckBox::toggled, appOptions, &QWidget::setEnabled);
check(hoverForm, "Battery", "hover/battery", true); check(hoverForm, "Battery", "hover/battery", true);
check(hoverForm, "Fan", "hover/fan", 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:")); hoverForm->addRow(new QLabel("Temperature:"));
auto *temperatures = new QWidget; auto *tempForm = new QFormLayout(temperatures); tempForm->setContentsMargins(24, 0, 0, 0); auto *temperatures = new QWidget; auto *tempForm = new QFormLayout(temperatures); tempForm->setContentsMargins(24, 0, 0, 0);
for (const auto &sensor : tooltipSensors(sensors)) { for (const auto &sensor : tooltipSensors(sensors)) {
@@ -205,7 +225,8 @@ void TrayPage::loadScale()
const bool frequency = unit == "MHz", temperature = unit == "°C", rate = unit == "W"; 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); 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); } 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(); const double low = m_values.value(scaleKey("minimum"), rate && !metric().id.startsWith("power/") ? -75 : 0).toDouble();
maximum = m_values.value(scaleKey("maximum"), maximum).toDouble(); maximum = m_values.value(scaleKey("maximum"), maximum).toDouble();
m_minimum->setValue(low); m_maximum->setMinimum(m_minimum->value() + 1); m_maximum->setValue(maximum); 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; QVector<TrayConfiguration> result;
for (int i = 0; i < m_count->value(); ++i) { for (int i = 0; i < m_count->value(); ++i) {
const auto *page = m_pages[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; return result;
} }
+6 -1
View File
@@ -19,6 +19,7 @@ struct TrayConfiguration {
Sensor metric; Sensor metric;
TrayStyle style; TrayStyle style;
QVariantMap values; QVariantMap values;
std::optional<Sensor> secondMetric;
}; };
class TrayPage : public QWidget { class TrayPage : public QWidget {
@@ -28,6 +29,10 @@ public:
TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QWidget *parent = nullptr); TrayPage(const QVariantMap &values, const QVector<Sensor> &sensors, QWidget *parent = nullptr);
QString mode() const { return m_mode->currentData().toString(); } QString mode() const { return m_mode->currentData().toString(); }
Sensor metric() const { return m_sensors[m_metric->currentIndex()]; } 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; TrayStyle iconStyle() const;
QVariantMap draft() const { return m_values; } QVariantMap draft() const { return m_values; }
void load(const QVariantMap &values); void load(const QVariantMap &values);
@@ -40,7 +45,7 @@ private:
QList<std::function<void()>> m_reload; QList<std::function<void()>> m_reload;
bool m_loading = false; bool m_loading = false;
QVector<Sensor> m_sensors; 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; QDoubleSpinBox *m_minimum, *m_maximum;
QFormLayout *m_scaleLayout; QFormLayout *m_scaleLayout;
QWidget *m_graphSettings; QWidget *m_graphSettings;
+21 -10
View File
@@ -119,7 +119,8 @@ QGroupBox *Window::sensorGroup(const QString &title, Chart *chart, const QVector
QVector<Sensor> main, extra; QVector<Sensor> main, extra;
for (const auto &sensor : sensors) { for (const auto &sensor : sensors) {
chart->addSeries(sensor.id, sensor.name, sensor.unit); 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); (temperatures && !sensor.primary ? extra : main).append(sensor);
} }
auto makeLegend = [&](const QVector<Sensor> &entries) { auto makeLegend = [&](const QVector<Sensor> &entries) {
@@ -310,15 +311,24 @@ void Window::updateTray()
if (config.mode == "icon") tray.icon->setIcon(windowIcon()); if (config.mode == "icon") tray.icon->setIcon(windowIcon());
else { else {
const double since = now - (config.mode == "graph" ? config.style.historyMs : 0); const double since = now - (config.mode == "graph" ? config.style.historyMs : 0);
QVector<QPointF> history; const auto historyFor = [&](const Sensor &sensor) {
if (metric.id == "cpu-usage") if (sensor.id == "cpu-usage") {
for (auto it = historyStart(m_usageHistory, since); it != m_usageHistory.cend(); ++it) history.append(*it); QVector<QPointF> history;
else if (metric.id == "battery" || metric.id == "battery-rate") history = m_batteryChart->history(metric.id, since); for (auto it = historyStart(m_usageHistory, since); it != m_usageHistory.cend(); ++it) history.append(*it);
else if (metric.id.startsWith("power/")) history = m_powerChart->history(metric.id, since); return history;
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)); 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)); tray.icon->setToolTip(trayTooltip(config.values, m_values, temperatures, m_batteryText, m_topAppText, m_powerMonitor.sensors()));
} }
} }
QVariantMap Window::controlValue(const QString &key) const QVariantMap Window::controlValue(const QString &key) const
@@ -531,7 +541,8 @@ void Window::sample()
sampleSensors(m_frequencies, m_frequencyChart); sampleSensors(m_temperatures, m_temperatureChart); sampleSensors(m_frequencies, m_frequencyChart); sampleSensors(m_temperatures, m_temperatureChart);
const auto power = m_powerMonitor.sample(m_fastTimer.interval()); const auto power = m_powerMonitor.sample(m_fastTimer.interval());
m_powerChart->sample(power, 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 QString ec = ecHwmon();
const auto rpm = readNumber(ec + "/fan1_input"); const auto rpm = readNumber(ec + "/fan1_input");
const auto mode = readNumber(ec + "/pwm1_enable"); 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()); QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write(value), value.size());
} }
private Q_SLOTS: 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() { void energyCounters() {
EnergyCounter counter; EnergyCounter counter;
const quint64 range = 262143328850ULL; const quint64 range = 262143328850ULL;
+115 -4
View File
@@ -21,6 +21,7 @@
#include <QSlider> #include <QSlider>
#include "graphdata.h" #include "graphdata.h"
#include "tooltip.h" #include "tooltip.h"
#include "readingformat.h"
#include "processusage.h" #include "processusage.h"
#include <QFile> #include <QFile>
#include <QDir> #include <QDir>
@@ -33,6 +34,112 @@
class WindowTest : public QObject { class WindowTest : public QObject {
Q_OBJECT Q_OBJECT
private Q_SLOTS: 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() { void hoverFormatting() {
const QVector<Sensor> sensors{{"cros_ec/peci-temp", "CPU", {}, "°C"}, {"spd5118/temp1", "Memory", {}, "°C"}, const QVector<Sensor> sensors{{"cros_ec/peci-temp", "CPU", {}, "°C"}, {"spd5118/temp1", "Memory", {}, "°C"},
{"nvme/Composite", "NVMe", {}, "°C"}}; {"nvme/Composite", "NVMe", {}, "°C"}};
@@ -52,14 +159,15 @@ private Q_SLOTS:
settings["tray/hover/temperature/memory"] = false; settings["tray/hover/temperature/nvme"] = false; 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, readings, temperatures, {}, apps), QString("CPU 47°C"));
QCOMPARE(trayTooltip(settings, {}, temperatures, {}, {}), QString("CPU —°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; settings["tray/hover/temperature/cpu"] = false;
QVERIFY(trayTooltip(settings, readings, temperatures, {}, {}).isEmpty()); QVERIFY(trayTooltip(settings, readings, temperatures, {}, {}).isEmpty());
QVariantMap battery{{"state", "Discharging"}, {"capacity", 75}, {"watts", 12.5}, {"remainingMWh", 56250}, {"fullMWh", 75000}}; 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"; 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; 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; battery["current_now"] = 0;
QVERIFY(batteryTooltip(battery, 80, {}).contains("Time remaining unavailable")); QVERIFY(batteryTooltip(battery, 80, {}).contains("Time remaining unavailable"));
battery["capacity"] = 80; battery["capacity"] = 80;
@@ -296,6 +404,7 @@ private Q_SLOTS:
first->findChild<QComboBox *>("trayHistory")->setCurrentIndex(0); first->findChild<QComboBox *>("trayHistory")->setCurrentIndex(0);
first->findChild<QCheckBox *>("border")->setChecked(false); first->findChild<QCheckBox *>("border")->setChecked(false);
last->findChild<QComboBox *>("trayDisplayMode")->setCurrentIndex(2); last->findChild<QComboBox *>("trayDisplayMode")->setCurrentIndex(2);
last->findChild<QComboBox *>("traySecondMetric")->setCurrentIndex(1);
last->findChild<QSpinBox *>("hover/topApps")->setValue(3); last->findChild<QSpinBox *>("hover/topApps")->setValue(3);
const auto firstValues = first->draft(), lastValues = last->draft(); const auto firstValues = first->draft(), lastValues = last->draft();
const auto original = page.draft(); const auto original = page.draft();
@@ -304,6 +413,8 @@ private Q_SLOTS:
QSignalSpy changed(&page, &TrayIconsPage::settingsChanged); QSignalSpy changed(&page, &TrayIconsPage::settingsChanged);
cards[2]->findChild<QToolButton *>("moveIconLeft")->click(); cards[2]->findChild<QToolButton *>("moveIconLeft")->click();
QCOMPARE(page.configurations()[1].values, lastValues); QCOMPARE(changed.count(), 1); 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(); cards[0]->findChild<QToolButton *>("moveIconRight")->click();
QCOMPARE(page.configurations()[0].values, lastValues); QCOMPARE(page.configurations()[0].values, lastValues);
QCOMPARE(page.configurations()[1].values, firstValues); 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); 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. QCOMPARE(chart.history("hidden").size(), 7201); // Not capped at 600, even when hidden.
chart.setSelected("hidden", true); 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); const auto end = double(start + 3600000);
chart.setHistoryWindow(historyRetentionMs, true); chart.setHistoryWindow(historyRetentionMs, true);
QCOMPARE(chart.timeRange(), qMakePair(double(start), end)); 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: { onUiVisibleChanged: {
if (uiVisible) { if (uiVisible) {
Window.window.requestActivate(); Window.window.requestActivate();
@@ -135,6 +143,18 @@ @@ -135,6 +143,24 @@
} }
authenticator.startAuthenticating(); authenticator.startAuthenticating();
} }
@@ -72,11 +72,17 @@
+ interval: 6000 + interval: 6000
+ running: root.viewVisible + running: root.viewVisible
+ onTriggered: authenticator.startAuthenticating() + onTriggered: authenticator.startAuthenticating()
+ }
+ FedoraToolsFingerprintRetry {
+ auth: authenticator
+ fingerprintType: ScreenLocker.Authenticator.Fingerprint
+ enabled: root.viewVisible && !authenticator.unlocked
+ && !graceLockTimer.running && !resumeAuthenticationTimer.running
+ } + }
onBlockUIChanged: { onBlockUIChanged: {
if (blockUI) { if (blockUI) {
fadeoutTimer.running = false; fadeoutTimer.running = false;
@@ -194,6 +214,6 @@ @@ -194,6 +220,6 @@
WallpaperFader { WallpaperFader {
anchors.fill: parent anchors.fill: parent
- state: lockScreenRoot.uiVisible ? "on" : "off" - state: lockScreenRoot.uiVisible ? "on" : "off"
@@ -84,7 +90,7 @@
source: wallpaper source: wallpaper
mainStack: mainStack mainStack: mainStack
footer: footer footer: footer
@@ -211,7 +231,7 @@ @@ -211,7 +237,7 @@
samples: 15 samples: 15
spread: 0.2 spread: 0.2
color : Qt.rgba(0, 0, 0, 0.7) color : Qt.rgba(0, 0, 0, 0.7)
@@ -93,6 +99,6 @@
Behavior on opacity { Behavior on opacity {
OpacityAnimator { OpacityAnimator {
duration: Kirigami.Units.veryLongDuration * 2 duration: Kirigami.Units.veryLongDuration * 2
@@ -265 +285 @@ @@ -265 +291 @@
- lockScreenUiVisible: lockScreenRoot.uiVisible - lockScreenUiVisible: lockScreenRoot.uiVisible
+ lockScreenUiVisible: true + lockScreenUiVisible: true
@@ -1,6 +1,6 @@
Name: plasma-always-show-unlock Name: plasma-always-show-unlock
Version: 0.1.0 Version: 0.1.0
Release: 10%{?dist} Release: 11%{?dist}
Summary: Immediately show the Plasma unlock prompt without requiring input Summary: Immediately show the Plasma unlock prompt without requiring input
License: MIT AND GPL-2.0-or-later License: MIT AND GPL-2.0-or-later
@@ -11,10 +11,13 @@ Source2: test-plasma-always-show-unlock
Source3: LockScreenUi.qml Source3: LockScreenUi.qml
Source4: LICENSE Source4: LICENSE
Source5: README.md Source5: README.md
Source6: FedoraToolsFingerprintRetry.qml
Source7: tst_fingerprintretry.qml
BuildArch: noarch BuildArch: noarch
BuildRequires: bash BuildRequires: bash
BuildRequires: patch BuildRequires: patch
BuildRequires: qt6-qtdeclarative-devel
Requires: bash Requires: bash
Requires: patch Requires: patch
Requires: plasma-desktop >= 6.7.4 Requires: plasma-desktop >= 6.7.4
@@ -31,6 +34,11 @@ patch after plasma-desktop updates and leaves unknown future layouts unchanged.
%check %check
bash %{SOURCE2} %{SOURCE0} %{SOURCE1} %{SOURCE3} 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
install -Dpm 0755 %{SOURCE0} \ install -Dpm 0755 %{SOURCE0} \
@@ -41,6 +49,15 @@ install -Dpm 0644 %{SOURCE4} \
%{buildroot}%{_licensedir}/%{name}/LICENSE %{buildroot}%{_licensedir}/%{name}/LICENSE
install -Dpm 0644 %{SOURCE5} \ install -Dpm 0644 %{SOURCE5} \
%{buildroot}%{_docdir}/%{name}/README.md %{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 %preun
if [ "$1" -eq 0 ]; then if [ "$1" -eq 0 ]; then
@@ -55,8 +72,14 @@ fi
%doc %{_docdir}/%{name}/README.md %doc %{_docdir}/%{name}/README.md
%{_libexecdir}/plasma-always-show-unlock %{_libexecdir}/plasma-always-show-unlock
%{_datadir}/plasma-always-show-unlock/LockScreenUi.patch %{_datadir}/plasma-always-show-unlock/LockScreenUi.patch
%{_datadir}/plasma/shells/org.kde.plasma.desktop/contents/lockscreen/FedoraToolsFingerprintRetry.qml
%changelog %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 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-10
- Clarify the package summary - Clarify the package summary
@@ -37,6 +37,8 @@ run_tool status >/dev/null
grep -q 'function onViewVisibleChanged()' "$target" grep -q 'function onViewVisibleChanged()' "$target"
grep -q 'id: resumeAuthenticationTimer' "$target" grep -q 'id: resumeAuthenticationTimer' "$target"
grep -q 'id: postGraceAuthenticationTimer' "$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: 1500' "$target"
grep -q 'interval: 6000' "$target" grep -q 'interval: 6000' "$target"
grep -q 'running: root.viewVisible' "$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 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC 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) find_package(ECM REQUIRED NO_MODULE)
list(APPEND CMAKE_MODULE_PATH "${ECM_MODULE_PATH}") list(APPEND CMAKE_MODULE_PATH "${ECM_MODULE_PATH}")
include(ECMQtDeclareLoggingCategory) include(ECMQtDeclareLoggingCategory)
@@ -25,8 +25,9 @@ ecm_qt_declare_logging_category(sources
add_executable(retrytest ${sources}) add_executable(retrytest ${sources})
target_include_directories(retrytest PRIVATE target_include_directories(retrytest PRIVATE
${CMAKE_CURRENT_BINARY_DIR} ${KSCREENLOCKER_SOURCE}/greeter) ${CMAKE_CURRENT_BINARY_DIR} ${KSCREENLOCKER_SOURCE}/greeter)
target_compile_definitions(retrytest PRIVATE HAVE_PAM_FAIL_DELAY) target_compile_definitions(retrytest PRIVATE HAVE_PAM_FAIL_DELAY
target_link_libraries(retrytest PRIVATE Qt6::Core Qt6::Test) 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() enable_testing()
add_test(NAME fingerprint-retry COMMAND retrytest) add_test(NAME fingerprint-retry COMMAND retrytest)
set_tests_properties(fingerprint-retry PROPERTIES TIMEOUT 30) set_tests_properties(fingerprint-retry PROPERTIES TIMEOUT 30)
@@ -2,6 +2,8 @@
#include <QSignalSpy> #include <QSignalSpy>
#include <QTest> #include <QTest>
#include <QQmlComponent>
#include <QQmlEngine>
#include <security/pam_appl.h> #include <security/pam_appl.h>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
@@ -38,9 +40,21 @@ int pam_authenticate(pam_handle_t *handle, int)
if (handle->service == "transient" && handle->attempts == 1) { if (handle->service == "transient" && handle->attempts == 1) {
return PAM_AUTHINFO_UNAVAIL; return PAM_AUTHINFO_UNAVAIL;
} }
if (handle->service == "timeout" && handle->attempts == 1) { if (handle->service == "attempt-limit" && handle->attempts == 1) {
return PAM_MAXTRIES; 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:"}; pam_message message{PAM_PROMPT_ECHO_OFF, "Test credential:"};
const pam_message *messages = &message; const pam_message *messages = &message;
pam_response *response = nullptr; pam_response *response = nullptr;
@@ -61,11 +75,60 @@ class RetryTest : public QObject
{ {
Q_OBJECT Q_OBJECT
private Q_SLOTS: 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() void retryWithActivePassword_data()
{ {
QTest::addColumn<QString>("service"); QTest::addColumn<QString>("service");
QTest::newRow("temporary unavailability") << QStringLiteral("transient"); QTest::newRow("temporary unavailability") << QStringLiteral("transient");
QTest::newRow("attempt timeout") << QStringLiteral("timeout"); QTest::newRow("exhausted attempts, manual retry") << QStringLiteral("attempt-limit");
} }
void retryWithActivePassword() void retryWithActivePassword()
@@ -21,6 +21,8 @@ install -m 0644 \
"$topdir/SOURCES/LockScreenUi.qml" "$topdir/SOURCES/LockScreenUi.qml"
install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE" install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
install -m 0644 "$repo_root/README.md" "$topdir/SOURCES/README.md" 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 \ rpmbuild \
--define "_topdir $topdir" \ --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 \ bash plasma-always-show-unlock/tests/test-plasma-always-show-unlock \
plasma-always-show-unlock/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 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 \ bash plasma-fingerprint-workaround/tests/test-controller \
plasma-fingerprint-workaround/plasma-fingerprint-workaround plasma-fingerprint-workaround/payload.conf plasma-fingerprint-workaround/plasma-fingerprint-workaround plasma-fingerprint-workaround/payload.conf