diff --git a/README.md b/README.md index 4b241d0..bc07acc 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -153 of them, under `tests/`. Run the lot, or a subset by pattern: +156 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 2d5db5e..d25275b 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -92,6 +92,14 @@ Singleton { label: "Battery", detail: "Show the charge level in the bar, on machines that have a battery" }, + // The number beside the icon, GNOME's "Show Battery Percentage". + // Off by default for the same reason GNOME ships it off: the icon + // already says what matters, and the number is for people who want it. + { + key: "showBatteryPercent", type: "bool", def: false, group: "vitals", + label: "Battery percentage", + detail: "Show the exact number beside the battery icon" + }, // Off by default: this is a coding-tool readout, not something a // general-purpose desktop should show without being asked. diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index 27c9f59..8a3f001 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -39,6 +39,7 @@ Singleton { readonly property bool showMemory: DesktopPreferences.get("showMemory") readonly property bool showGpu: DesktopPreferences.get("showGpu") readonly property bool showBattery: DesktopPreferences.get("showBattery") + readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent") readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage") // ── Battery ───────────────────────────────────────────────────────────── diff --git a/config/dot/quickshell/modules/bar/StatusCluster.qml b/config/dot/quickshell/modules/bar/StatusCluster.qml index 7b1273b..b95ed7a 100644 --- a/config/dot/quickshell/modules/bar/StatusCluster.qml +++ b/config/dot/quickshell/modules/bar/StatusCluster.qml @@ -130,6 +130,16 @@ Pill { // battery has actually been read, so this is not a zero that looks like a // flat cell. Same shape as the graphics field in VitalsWidget, which gates // on both the preference and the hardware. + // + // One color for the icon and the number beside it: two different colors + // would read as two indicators. + readonly property color batteryColor: { + if (Battery.critical) return Theme.danger; + if (Battery.low) return Theme.warn; + if (Battery.charging) return Theme.ok; + return Theme.fg; + } + StatusGlyph { visible: Settings.showBattery && Battery.available glyph: { @@ -141,11 +151,24 @@ Pill { // md-battery_10 .. md-battery_90 are consecutive from F007A. return String.fromCodePoint(0xF007A + (level / 10) - 1); } - color: { - if (Battery.critical) return Theme.danger; - if (Battery.low) return Theme.warn; - if (Battery.charging) return Theme.ok; - return Theme.fg; + color: root.batteryColor + } + + // The exact number, for the people who ask the icon to be more specific -- + // GNOME's "Show Battery Percentage", living under the same gates as the + // icon it annotates. + Text { + visible: Settings.showBattery && Settings.showBatteryPercent && Battery.available + anchors.verticalCenter: parent.verticalCenter + text: Math.round(Battery.percent) + "%" + color: root.batteryColor + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + + Behavior on color { + ColorAnimation { + duration: Theme.durFast + } } } } diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 880656e..4be6aba 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -381,6 +381,7 @@ SettingsPage { // Only where there is a battery to report on. A desktop should not be // offered a switch for a readout it can never show. ToggleRow { setting: "showBattery"; visible: Battery.available } + ToggleRow { setting: "showBatteryPercent"; visible: Battery.available && Settings.showBattery } ToggleRow { setting: "showAgentUsage"; divider: true } // Refresh interval was on the Home page, which split one concept across // two pages -- what the vitals show here, how often they update there. diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index 5320687..eaa935d 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -121,6 +121,25 @@ SettingsPage { SliderRow { setting: "suspendMinutesBattery"; zeroLabel: "Never"; divider: false } } + // What the lid does. Informational by design: the decision follows what + // is connected (see services/LidPolicy.qml), and the deliberate absence + // of an override is part of the design -- a lid switch set to "never + // suspend" is a laptop that cooks in a bag. Saying so here beats leaving + // the behavior undiscoverable. + SettingsCard { + visible: Battery.available + title: "When the lid closes" + subtitle: "Decided by what is connected rather than by a setting: with an external display attached the machine is docked and keeps running; on its own it suspends, locking on the way down." + + TextRow { + label: "Right now" + value: LidPolicy.inhibited + ? "Stays awake — an external display is connected" + : "Suspends" + divider: false + } + } + // Only shown when the numbers are actually contradictory, rather than as a // permanent warning nobody reads. SettingsCard { diff --git a/config/dot/quickshell/modules/settings/UsersPage.qml b/config/dot/quickshell/modules/settings/UsersPage.qml index 8e6fedb..c18ba75 100644 --- a/config/dot/quickshell/modules/settings/UsersPage.qml +++ b/config/dot/quickshell/modules/settings/UsersPage.qml @@ -55,7 +55,12 @@ SettingsPage { root.newUserIsAdministrator = false; } - Component.onCompleted: UserAccounts.refresh() + Component.onCompleted: { + UserAccounts.refresh(); + // Probing fprintd bus-activates it, so it waits for the page rather + // than costing every shell launch. + Fingerprint.refresh(); + } TextRow { visible: UserAccounts.lastError !== "" @@ -260,6 +265,58 @@ SettingsPage { } } + // ── Fingerprint ────────────────────────────────────────────────────────── + // + // Hidden in full on a machine with no reader. Two systems make this work + // and the card keeps them honest with each other: fprintd holds the + // enrolled prints (GNOME's Users panel owns that dialog, so enrollment + // hands off the same way password-adjacent panels do), and authselect + // decides whether PAM asks the reader at all -- a print enrolled while + // that is off does nothing, which reads as "fingerprint is broken". + + SettingsCard { + visible: Fingerprint.readerPresent + title: "Fingerprint" + subtitle: Fingerprint.readerName !== "" + ? Fingerprint.readerName + : "A fingerprint reader is present." + + SwitchRow { + label: "Unlock with a fingerprint" + detail: { + if (Fingerprint.enrolled.length === 0) + return "Enroll a finger below first; until then the password is the only way in"; + return Fingerprint.pamEnabled + ? "The lock screen and sudo accept an enrolled finger, with the password as fallback" + : "Enrolled fingers are ignored until this is on"; + } + checked: Fingerprint.pamEnabled + enabled: !Fingerprint.busy + onToggled: value => Fingerprint.setUnlockEnabled(value) + } + + ActionRow { + label: "Enrolled fingers" + detail: Fingerprint.enrolled.length === 0 + ? "None yet" + : Fingerprint.enrolled.map(finger => Fingerprint.fingerLabel(finger)).join(", ") + action: "Manage…" + divider: Fingerprint.lastError !== "" + // GNOME's Users panel owns the enrollment dialog; growing our own + // means reimplementing a guided capture flow fprintd already has + // a good one of. + onTriggered: SystemSettings.openGnomePanel("system", "users") + } + + TextRow { + visible: Fingerprint.lastError !== "" + label: "Fingerprint needs attention" + detail: Fingerprint.lastError + value: "" + divider: false + } + } + // ── Everyone else ──────────────────────────────────────────────────────── SettingsCard { diff --git a/config/dot/quickshell/scripts/panama-fingerprint b/config/dot/quickshell/scripts/panama-fingerprint new file mode 100755 index 0000000..760c045 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-fingerprint @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# Fingerprint state and the one privileged switch, for the Users page. +# +# Two independent facts make a working fingerprint login, and conflating them +# is how the feature usually confuses people: fprintd must hold at least one +# enrolled print (GNOME's Users panel owns that dialog, and Panama hands off +# to it), and PAM must be told to ask the reader at all, which on Fedora is +# authselect's `with-fingerprint` feature. This helper reports both and can +# flip the second. +# +# Usage: +# panama-fingerprint status -> {"reader":bool,"readerName":"","enrolled":[],"pamEnabled":bool,"error":""} +# panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit) +# +# authselect is baseline Fedora (it manages PAM for the whole install), and +# fprintd ships with Workstation; a machine with neither simply reports no +# reader, which hides the card. + +set -uo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" + +emit() { + jq -cn \ + --argjson reader "$1" \ + --arg readerName "$2" \ + --argjson enrolled "$3" \ + --argjson pamEnabled "$4" \ + --arg error "$5" \ + '{reader: $reader, readerName: $readerName, enrolled: $enrolled, + pamEnabled: $pamEnabled, error: $error}' +} + +cmd_status() { + command -v fprintd-list >/dev/null 2>&1 || { emit false "" '[]' false ""; return; } + + # fprintd-list both answers "is there a reader" (fprintd is bus-activated, + # so this also copes with the daemon not running yet) and names the + # enrolled fingers in one call. + local listing + if ! listing="$(timeout 10 fprintd-list "$USER" 2>&1)"; then + # "No devices available" is the normal no-reader machine; anything + # else is a real problem worth surfacing. + if grep -qi 'no devices' <<<"$listing"; then + emit false "" '[]' false "" + else + emit false "" '[]' false "fprintd did not answer: $(head -1 <<<"$listing")" + fi + return + fi + + # "Fingerprints for user gib on FocalTech ... (press):" carries the reader + # product name; " - #0: right-index-finger" lines carry the enrollment. + local name enrolled pam + name="$(sed -n 's/^Fingerprints for user [^ ]* on \(.*\) (\w*):$/\1/p' <<<"$listing" | head -1)" + enrolled="$(sed -n 's/^ *- #[0-9]*: //p' <<<"$listing" | jq -Rn '[inputs]')" + pam=false + authselect current 2>/dev/null | grep -q 'with-fingerprint' && pam=true + + emit true "$name" "$enrolled" "$pam" "" +} + +cmd_set_unlock() { + local verb reason + case "$1" in + on) verb=enable-feature + reason="Turning on fingerprint login: telling PAM (via authselect) to ask the fingerprint reader when unlocking" ;; + off) verb=disable-feature + reason="Turning off fingerprint login: telling PAM (via authselect) to stop asking the fingerprint reader" ;; + *) echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1 ;; + esac + + local sudo_cmd=(sudo) + [[ -x "$PANAMA_PATH/bin/panama-sudo" ]] && sudo_cmd=( + "$PANAMA_PATH/bin/panama-sudo" --reason "$reason" -- + ) + "${sudo_cmd[@]}" authselect "$verb" with-fingerprint +} + +case "${1:-}" in + status) cmd_status ;; + set-unlock) [[ -n "${2:-}" ]] || { echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1; } + cmd_set_unlock "$2" ;; + *) echo 'usage: panama-fingerprint status | set-unlock on|off' >&2; exit 1 ;; +esac diff --git a/config/dot/quickshell/services/Fingerprint.qml b/config/dot/quickshell/services/Fingerprint.qml new file mode 100644 index 0000000..b3ef675 --- /dev/null +++ b/config/dot/quickshell/services/Fingerprint.qml @@ -0,0 +1,95 @@ +pragma Singleton + +// The fingerprint reader, for the Users page. +// +// Two facts, owned by two different systems: fprintd holds the enrolled +// prints (GNOME's Users panel owns the enrollment dialog and Panama hands off +// to it), and authselect decides whether PAM asks the reader at unlock. Both +// come through scripts/panama-fingerprint, and the one privileged change -- +// flipping authselect's with-fingerprint feature -- prompts through polkit +// with a stated reason, like everything else on that page. +// +// Read when the page opens rather than at shell startup: probing fprintd +// bus-activates the daemon, and most sessions never open this page. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-fingerprint" + + property bool readerPresent: false + property string readerName: "" + property var enrolled: [] + property bool pamEnabled: false + property bool scanned: false + property bool busy: false + property string lastError: "" + + // "right-index-finger" -> "Right index finger". Presentation lives here + // rather than in the page, the way PowerProfiles.label does, so nothing + // can disagree about what a finger is called. + function fingerLabel(finger: string): string { + const words = String(finger).split("-").join(" "); + return words.slice(0, 1).toUpperCase() + words.slice(1); + } + + function refresh(): void { + if (!query.running) + query.running = true; + } + + function setUnlockEnabled(on: bool): void { + if (root.busy) + return; + root.busy = true; + root.lastError = ""; + apply.command = [root.helperPath, "set-unlock", on ? "on" : "off"]; + apply.running = true; + } + + Process { + id: query + command: [root.helperPath, "status"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.readerPresent = parsed.reader === true; + root.readerName = String(parsed.readerName ?? ""); + root.enrolled = Array.isArray(parsed.enrolled) ? parsed.enrolled : []; + root.pamEnabled = parsed.pamEnabled === true; + if (String(parsed.error ?? "") !== "") + root.lastError = String(parsed.error); + } catch (error) { + root.readerPresent = false; + root.lastError = "Could not read the fingerprint helper's output."; + console.warn("Fingerprint: could not parse helper output:", error); + } + root.scanned = true; + } + } + } + + Process { + id: apply + stderr: StdioCollector { + // A dismissed polkit prompt is a normal outcome on this page, not + // a failure to report. + onStreamFinished: { + const text = this.text.trim(); + if (text !== "" && !/dismissed|not authorized/i.test(text)) + root.lastError = text; + } + } + // Re-read rather than assuming: authselect may refuse, and the + // prompt may have been dismissed. + onExited: { + root.busy = false; + root.refresh(); + } + } +} diff --git a/config/local/share/vicinae/scripts/settings-appearance b/config/local/share/vicinae/scripts/settings-appearance index 200a0f8..e8eeb1f 100755 --- a/config/local/share/vicinae/scripts/settings-appearance +++ b/config/local/share/vicinae/scripts/settings-appearance @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Appearance in Settings. -# @vicinae.keywords ["settings", "24-hour time", "show seconds", "show weekday", "processor", "memory", "graphics", "battery", "claude usage", "inner gaps", "outer gaps", "border width", "corner radius"] +# @vicinae.keywords ["settings", "24-hour time", "show seconds", "show weekday", "processor", "memory", "graphics", "battery", "battery percentage", "claude usage", "inner gaps", "outer gaps", "border width"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page appearance diff --git a/docs/settings.md b/docs/settings.md index 0a7c2c2..92c214d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` after changing the schema; a contract fails when this copy is stale. -147 settings across 29 groups. 70 of them are applied to the compositor and confirmed by reading the value back. +148 settings across 29 groups. 70 of them are applied to the compositor and confirmed by reading the value back. ## accessibility @@ -317,6 +317,7 @@ Found on **Appearance**. | **Memory**
`showMemory` | true | Show memory usage beside the workspace indicator | | **Graphics**
`showGpu` | true | Show graphics usage beside the workspace indicator | | **Battery**
`showBattery` | true | Show the charge level in the bar, on machines that have a battery | +| **Battery percentage**
`showBatteryPercent` | false | Show the exact number beside the battery icon | | **Claude usage**
`showAgentUsage` | false | Show how much of the Claude subscription has been used, beside the other vitals | | **Vitals refresh**
`vitalsIntervalMs` | 2000 ms | How often processor, memory, and graphics usage update. Range 500–10000. | diff --git a/setup/packages/hyprland-packages b/setup/packages/hyprland-packages index 41050e0..32fd5d5 100644 --- a/setup/packages/hyprland-packages +++ b/setup/packages/hyprland-packages @@ -4,6 +4,10 @@ adwaita-icon-theme adwaita-sans-fonts brightnessctl ddcutil +# The Users page's fingerprint card reads enrollment through fprintd-list. +# Workstation ships fprintd; declaring it keeps the card from silently +# missing on an install that started from less. +fprintd gpu-screen-recorder grim grimblast diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract index 865c163..bcb3afa 100755 --- a/tests/quickshell/declared-dependencies-contract +++ b/tests/quickshell/declared-dependencies-contract @@ -70,6 +70,7 @@ package_for() { case "$1" in zbarimg) printf 'zbar' ;; fc-list|fc-match) printf 'fontconfig' ;; + fprintd-list) printf 'fprintd' ;; lspci) printf 'pciutils' ;; getenforce) printf 'libselinux-utils' ;; nmcli) printf 'NetworkManager' ;; diff --git a/tests/quickshell/fingerprint-contract b/tests/quickshell/fingerprint-contract new file mode 100755 index 0000000..539d1ba --- /dev/null +++ b/tests/quickshell/fingerprint-contract @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +# Fingerprint login, which is two systems that must be kept honest with each +# other: fprintd holds the enrolled prints, authselect decides whether PAM +# asks the reader. A print enrolled while with-fingerprint is off does +# nothing, and that silence -- "I enrolled a finger and nothing happened" -- +# is the failure this card exists to name. +# +# The helper is the parse surface, so it runs for real against stub fprintd +# and authselect; the page and service checks are structural. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint" +service="$repo_dir/config/dot/quickshell/services/Fingerprint.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/UsersPage.qml" + +fail() { + printf 'fingerprint contract: %s\n' "$1" >&2 + exit 1 +} + +# ── Wiring ─────────────────────────────────────────────────────────────────── + +rg -Fq 'visible: Fingerprint.readerPresent' "$page" \ + || fail 'the card is not hidden on machines with no reader' +rg -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \ + || fail 'the unlock switch does not drive authselect' +rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$page" \ + || fail 'enrollment does not hand off to the GNOME Users panel' +rg -Fq 'Fingerprint.refresh()' "$page" \ + || fail 'the page never reads the fingerprint state' +rg -Fq 'authselect' "$helper" && rg -Fq 'with-fingerprint' "$helper" \ + || fail 'the helper does not manage the authselect feature' +rg -Fq -- '--reason' "$helper" \ + || fail 'the privileged change carries no stated reason' +rg -Fq 'function fingerLabel' "$service" \ + || fail 'finger names have no single place to be presented from' + +# ── The helper against stub fprintd and authselect ─────────────────────────── + +stub_dir="$(mktemp -d)" +state_dir="$(mktemp -d)" +trap 'rm -rf "$stub_dir" "$state_dir"' EXIT + +cat >"$stub_dir/fprintd-list" <"$stub_dir/authselect" <>"$state_dir/authselect-log" +if [[ "\$1" == "current" ]]; then + echo 'Profile ID: local' + [[ -e "$state_dir/pam-on" ]] && echo '- with-fingerprint' + exit 0 +fi +STUB + +# PANAMA_PATH pointed at an empty directory forces the plain-sudo fallback, +# which the stub records instead of escalating. +cat >"$stub_dir/sudo" <>"$state_dir/sudo-log" +exec "\$@" +STUB +chmod +x "$stub_dir"/fprintd-list "$stub_dir"/authselect "$stub_dir"/sudo + +run() { PANAMA_PATH="$state_dir" PATH="$stub_dir:$PATH" "$helper" "$@"; } + +status="$(run status)" +jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \ + || fail "the reader name did not parse: $status" +jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \ + || fail "enrolled fingers did not parse: $status" +jq -e '.pamEnabled == false and .error == ""' <<<"$status" >/dev/null \ + || fail "authselect state misread as enabled: $status" + +touch "$state_dir/pam-on" +jq -e '.pamEnabled == true' <<<"$(run status)" >/dev/null \ + || fail 'with-fingerprint enabled was not detected' + +# No reader is a normal machine, not an error. +touch "$state_dir/no-reader" +jq -e '.reader == false and .error == ""' <<<"$(run status)" >/dev/null \ + || fail "a readerless machine was reported as a problem: $(run status)" +rm -f "$state_dir/no-reader" + +# The privileged change goes through, with the right feature name. +run set-unlock on >/dev/null +grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/sudo-log" \ + || fail 'set-unlock on did not enable the authselect feature' +run set-unlock off >/dev/null +grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/sudo-log" \ + || fail 'set-unlock off did not disable the authselect feature' + +printf 'fingerprint contract: ok\n' diff --git a/tests/quickshell/gnome-handoff-contract b/tests/quickshell/gnome-handoff-contract index 819fbaf..b592db4 100755 --- a/tests/quickshell/gnome-handoff-contract +++ b/tests/quickshell/gnome-handoff-contract @@ -49,6 +49,7 @@ declare -A OWNED=( # Anything here must be justified, not merely tolerated. declare -A ALLOWED=( ["OnlineAccountsPage.qml:online-accounts"]="adding an account requires GOA's own dialog" + ["UsersPage.qml:system-users"]="fingerprint enrollment requires fprintd's guided capture flow, and GNOME's Users panel carries the only good dialog for it" ) pages="$(grep -oE '\{ *page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/' | sort -u)"