From ddb912d4ba6156efa56de065cfe98b51c64079fd Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 20 Sep 2026 20:48:34 +0000 Subject: [PATCH] Publish Framework tray enhancements and idle fingerprint retry --- README.md | 9 +- framework-laptop-tools/README.md | 22 ++- .../framework-laptop-tools.spec | 12 +- framework-laptop-tools/src/chart.cpp | 4 +- framework-laptop-tools/src/hardware.cpp | 44 ++++-- framework-laptop-tools/src/hardware.h | 3 + framework-laptop-tools/src/readingformat.h | 12 ++ framework-laptop-tools/src/tooltip.cpp | 18 ++- framework-laptop-tools/src/tooltip.h | 5 +- framework-laptop-tools/src/tray.cpp | 31 ++++- framework-laptop-tools/src/tray.h | 6 +- framework-laptop-tools/src/traypage.cpp | 27 +++- framework-laptop-tools/src/traypage.h | 7 +- framework-laptop-tools/src/window.cpp | 31 +++-- .../tests/test-hardware.cpp | 48 +++++++ framework-laptop-tools/tests/test-window.cpp | 119 +++++++++++++++- .../FedoraToolsFingerprintRetry.qml | 67 +++++++++ plasma-always-show-unlock/LockScreenUi.patch | 14 +- .../plasma-always-show-unlock.spec | 25 +++- .../tests/test-plasma-always-show-unlock | 2 + .../tests/tst_fingerprintretry.qml | 131 ++++++++++++++++++ .../tests/authentication/CMakeLists.txt | 7 +- .../tests/authentication/retrytest.cpp | 67 ++++++++- scripts/build-plasma-always-show-unlock-rpm | 2 + scripts/test-all | 2 + 25 files changed, 654 insertions(+), 61 deletions(-) create mode 100644 framework-laptop-tools/src/readingformat.h create mode 100644 plasma-always-show-unlock/FedoraToolsFingerprintRetry.qml create mode 100644 plasma-always-show-unlock/tests/tst_fingerprintretry.qml diff --git a/README.md b/README.md index 8a3f9d4..292c065 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/framework-laptop-tools/README.md b/framework-laptop-tools/README.md index 8e06315..056b544 100644 --- a/framework-laptop-tools/README.md +++ b/framework-laptop-tools/README.md @@ -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 diff --git a/framework-laptop-tools/framework-laptop-tools.spec b/framework-laptop-tools/framework-laptop-tools.spec index dbce6b9..0efe38b 100644 --- a/framework-laptop-tools/framework-laptop-tools.spec +++ b/framework-laptop-tools/framework-laptop-tools.spec @@ -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 diff --git a/framework-laptop-tools/src/chart.cpp b/framework-laptop-tools/src/chart.cpp index 0dbf009..493ffe9 100644 --- a/framework-laptop-tools/src/chart.cpp +++ b/framework-laptop-tools/src/chart.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT #include "chart.h" #include "graphdata.h" +#include "readingformat.h" #include #include #include @@ -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'); } diff --git a/framework-laptop-tools/src/hardware.cpp b/framework-laptop-tools/src/hardware.cpp index 0e3a513..661adf2 100644 --- a/framework-laptop-tools/src/hardware.cpp +++ b/framework-laptop-tools/src/hardware.cpp @@ -72,7 +72,26 @@ QVector temperatureSensors(const QString &sys) QVector frequencySensors(const QString &sys) { QVector 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 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 sensorValue(const Sensor &sensor) { - 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; } + if (sensor.id == "cpu" || !sensor.frequencyPaths.isEmpty()) { + QStringList paths = sensor.frequencyPaths; + if (sensor.id == "cpu") { + const QDir dir(sensor.path); + for (const auto &policy : dir.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot)) + paths.append(dir.filePath(policy + "/scaling_cur_freq")); } - return count ? std::optional(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((sensor.maximum ? maximum : total / count) * sensor.scale) : std::nullopt; } if (sensor.unit == "°C") { const QString fault = sensor.path.chopped(6) + "_fault"; diff --git a/framework-laptop-tools/src/hardware.h b/framework-laptop-tools/src/hardware.h index 8e5650d..8145f57 100644 --- a/framework-laptop-tools/src/hardware.h +++ b/framework-laptop-tools/src/hardware.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT #pragma once #include +#include #include #include #include @@ -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 readNumber(const QString &path); diff --git a/framework-laptop-tools/src/readingformat.h b/framework-laptop-tools/src/readingformat.h new file mode 100644 index 0000000..09db691 --- /dev/null +++ b/framework-laptop-tools/src/readingformat.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include + +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; +} diff --git a/framework-laptop-tools/src/tooltip.cpp b/framework-laptop-tools/src/tooltip.cpp index 41b791b..f459d96 100644 --- a/framework-laptop-tools/src/tooltip.cpp +++ b/framework-laptop-tools/src/tooltip.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT #include "tooltip.h" +#include "readingformat.h" #include #include @@ -15,9 +16,9 @@ QVector tooltipSensors(const QVector &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 &values, - const QVector &temperatures, const QString &battery, const QStringList &topApps) + const QVector &temperatures, const QString &battery, const QStringList &topApps, + const QVector &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 &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'); diff --git a/framework-laptop-tools/src/tooltip.h b/framework-laptop-tools/src/tooltip.h index 03a736e..9e6f643 100644 --- a/framework-laptop-tools/src/tooltip.h +++ b/framework-laptop-tools/src/tooltip.h @@ -4,8 +4,9 @@ struct TooltipSensor { QString key, name, id; }; QVector tooltipSensors(const QVector &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 &values, - const QVector &temperatures, const QString &battery, const QStringList &topApps); + const QVector &temperatures, const QString &battery, const QStringList &topApps, + const QVector &powerSensors = {}); diff --git a/framework-laptop-tools/src/tray.cpp b/framework-laptop-tools/src/tray.cpp index 3c44b27..7198f1e 100644 --- a/framework-laptop-tools/src/tray.cpp +++ b/framework-laptop-tools/src/tray.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT #include "tray.h" #include "graphdata.h" +#include "readingformat.h" #include #include #include @@ -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 &values, const QString &unit, const TrayStyle &style, qint64 now) +QIcon telemetryIcon(bool graph, const QVector &values, const QString &unit, const TrayStyle &style, qint64 now, + std::optional 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 &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); - 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); + 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 (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); } diff --git a/framework-laptop-tools/src/tray.h b/framework-laptop-tools/src/tray.h index 97d05b3..d056a3b 100644 --- a/framework-laptop-tools/src/tray.h +++ b/framework-laptop-tools/src/tray.h @@ -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 &values, const QString &unit, const TrayStyle &style, - qint64 now = QDateTime::currentMSecsSinceEpoch()); + qint64 now = QDateTime::currentMSecsSinceEpoch(), std::optional second = {}); diff --git a/framework-laptop-tools/src/traypage.cpp b/framework-laptop-tools/src/traypage.cpp index b7f7067..09da9cc 100644 --- a/framework-laptop-tools/src/traypage.cpp +++ b/framework-laptop-tools/src/traypage.cpp @@ -53,6 +53,11 @@ TrayPage::TrayPage(const QVariantMap &values, const QVector &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 &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 &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 &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 &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 TrayIconsPage::configurations() const QVector 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; } diff --git a/framework-laptop-tools/src/traypage.h b/framework-laptop-tools/src/traypage.h index 5c1b685..cd1e4ae 100644 --- a/framework-laptop-tools/src/traypage.h +++ b/framework-laptop-tools/src/traypage.h @@ -19,6 +19,7 @@ struct TrayConfiguration { Sensor metric; TrayStyle style; QVariantMap values; + std::optional secondMetric; }; class TrayPage : public QWidget { @@ -28,6 +29,10 @@ public: TrayPage(const QVariantMap &values, const QVector &sensors, QWidget *parent = nullptr); QString mode() const { return m_mode->currentData().toString(); } Sensor metric() const { return m_sensors[m_metric->currentIndex()]; } + std::optional secondMetric() const { + return m_secondMetric->currentIndex() > 0 + ? std::optional(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> m_reload; bool m_loading = false; QVector 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; diff --git a/framework-laptop-tools/src/window.cpp b/framework-laptop-tools/src/window.cpp index 93921a6..9a757c4 100644 --- a/framework-laptop-tools/src/window.cpp +++ b/framework-laptop-tools/src/window.cpp @@ -119,7 +119,8 @@ QGroupBox *Window::sensorGroup(const QString &title, Chart *chart, const QVector QVector 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 &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); - QVector 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)); + const auto historyFor = [&](const Sensor &sensor) { + if (sensor.id == "cpu-usage") { + QVector history; + for (auto it = historyStart(m_usageHistory, since); it != m_usageHistory.cend(); ++it) history.append(*it); + return history; + } + 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 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 @@ -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"); diff --git a/framework-laptop-tools/tests/test-hardware.cpp b/framework-laptop-tools/tests/test-hardware.cpp index 80e397f..9e5bda9 100644 --- a/framework-laptop-tools/tests/test-hardware.cpp +++ b/framework-laptop-tools/tests/test-hardware.cpp @@ -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(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(3000)); + QCOMPARE(sensorValue(sensors[1]), std::optional(1500)); + QCOMPARE(sensorValue(sensors[2]), std::optional(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(3000)); + QCOMPARE(sensorValue(sensors[4]), std::optional(2000)); + put(base + "policy0/scaling_cur_freq", "4000000"); + QCOMPARE(sensorValue(sensors[4]), std::optional(4000)); + put(base + "policy0/scaling_cur_freq", "1000000"); + QCOMPARE(sensorValue(sensors[4]), std::optional(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(2000)); + QCOMPARE(sensorValue(sensors[4]), std::optional(2000)); + put(base + "policy1/scaling_cur_freq", "unavailable"); + QVERIFY(!sensorValue(sensors[1])); + QVERIFY(!sensorValue(sensors[4])); + QCOMPARE(sensorValue(sensors[2]), std::optional(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; diff --git a/framework-laptop-tools/tests/test-window.cpp b/framework-laptop-tools/tests/test-window.cpp index 616f831..fe3cbf7 100644 --- a/framework-laptop-tools/tests/test-window.cpp +++ b/framework-laptop-tools/tests/test-window.cpp @@ -21,6 +21,7 @@ #include #include "graphdata.h" #include "tooltip.h" +#include "readingformat.h" #include "processusage.h" #include #include @@ -33,6 +34,112 @@ class WindowTest : public QObject { Q_OBJECT private Q_SLOTS: + void readingFormat_data() { + QTest::addColumn("value"); QTest::addColumn("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 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 sensors{{"cpu-usage", "CPU usage", {}, "%"}, {"cpu/p/max", "P-cores maximum", {}, "MHz"}}; + TrayPage page(QVariantMap{{"tray/mode", "number"}}, sensors); + auto *second = page.findChild("traySecondMetric"); + auto *mode = page.findChild("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 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 power{sensors[0], sensors[1]}; + const QMap values{{power[0].id, 4.25}, {power[1].id, 12.6}}; + TrayPage page(settings, sensors); + auto *package = page.findChild("hover/" + power[0].id); + auto *cores = page.findChild("hover/" + power[1].id); + QVERIFY(package); QVERIFY(cores); + QVERIFY(!package->isChecked()); QVERIFY(!cores->isChecked()); + QVERIFY(!page.findChild("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 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("trayHistory")->setCurrentIndex(0); first->findChild("border")->setChecked(false); last->findChild("trayDisplayMode")->setCurrentIndex(2); + last->findChild("traySecondMetric")->setCurrentIndex(1); last->findChild("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("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("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)); diff --git a/plasma-always-show-unlock/FedoraToolsFingerprintRetry.qml b/plasma-always-show-unlock/FedoraToolsFingerprintRetry.qml new file mode 100644 index 0000000..4623ca2 --- /dev/null +++ b/plasma-always-show-unlock/FedoraToolsFingerprintRetry.qml @@ -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(); + } + } +} diff --git a/plasma-always-show-unlock/LockScreenUi.patch b/plasma-always-show-unlock/LockScreenUi.patch index 187e589..1170293 100644 --- a/plasma-always-show-unlock/LockScreenUi.patch +++ b/plasma-always-show-unlock/LockScreenUi.patch @@ -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 diff --git a/plasma-always-show-unlock/plasma-always-show-unlock.spec b/plasma-always-show-unlock/plasma-always-show-unlock.spec index 72dc7a9..e6bdf38 100644 --- a/plasma-always-show-unlock/plasma-always-show-unlock.spec +++ b/plasma-always-show-unlock/plasma-always-show-unlock.spec @@ -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 diff --git a/plasma-always-show-unlock/tests/test-plasma-always-show-unlock b/plasma-always-show-unlock/tests/test-plasma-always-show-unlock index b050a31..bc39326 100755 --- a/plasma-always-show-unlock/tests/test-plasma-always-show-unlock +++ b/plasma-always-show-unlock/tests/test-plasma-always-show-unlock @@ -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" diff --git a/plasma-always-show-unlock/tests/tst_fingerprintretry.qml b/plasma-always-show-unlock/tests/tst_fingerprintretry.qml new file mode 100644 index 0000000..b4cb6f3 --- /dev/null +++ b/plasma-always-show-unlock/tests/tst_fingerprintretry.qml @@ -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); + } +} diff --git a/plasma-fingerprint-workaround/tests/authentication/CMakeLists.txt b/plasma-fingerprint-workaround/tests/authentication/CMakeLists.txt index 68d2421..1f36f39 100644 --- a/plasma-fingerprint-workaround/tests/authentication/CMakeLists.txt +++ b/plasma-fingerprint-workaround/tests/authentication/CMakeLists.txt @@ -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) diff --git a/plasma-fingerprint-workaround/tests/authentication/retrytest.cpp b/plasma-fingerprint-workaround/tests/authentication/retrytest.cpp index ad02fa1..738f5ad 100644 --- a/plasma-fingerprint-workaround/tests/authentication/retrytest.cpp +++ b/plasma-fingerprint-workaround/tests/authentication/retrytest.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -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("automaticRetry"); + QTest::newRow("automatic retry") << true; + QTest::newRow("without the new retry") << false; + } + + void idleFingerprintRetriesThroughQml() + { + QFETCH(bool, automaticRetry); + qmlRegisterUncreatableType("org.kde.kscreenlocker", 1, 0, "Authenticator", "Test instance only"); + auto password = std::make_unique(QStringLiteral("password"), QStringLiteral("test")); + auto fingerprint = std::make_unique(QStringLiteral("idle"), QStringLiteral("test"), PamAuthenticator::Fingerprint); + QSignalSpy passwordPrompts(password.get(), &PamAuthenticator::promptForSecret); + QSignalSpy fingerprintPrompts(fingerprint.get(), &PamAuthenticator::promptForSecret); + std::vector> 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 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("service"); QTest::newRow("temporary unavailability") << QStringLiteral("transient"); - QTest::newRow("attempt timeout") << QStringLiteral("timeout"); + QTest::newRow("exhausted attempts, manual retry") << QStringLiteral("attempt-limit"); } void retryWithActivePassword() diff --git a/scripts/build-plasma-always-show-unlock-rpm b/scripts/build-plasma-always-show-unlock-rpm index aaae502..b7522b8 100755 --- a/scripts/build-plasma-always-show-unlock-rpm +++ b/scripts/build-plasma-always-show-unlock-rpm @@ -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" \ diff --git a/scripts/test-all b/scripts/test-all index 3094616..19fa821 100755 --- a/scripts/test-all +++ b/scripts/test-all @@ -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