feat(quickshell): add polkit authentication agent

Registers a polkit agent for the logind session and presents its requests in
the hyprchrome panel chrome. PolkitPrompt owns the agent, the layer-shell
surface and focus; PolkitPromptContent is the headlessly renderable visual
core, staged by tests/PolkitPromptHeadless.qml.

Replaces terra's hyprpolkitagent autostart, which had been dead for a while:
the unit was never installed, so the start failed silently and the session
ran with no polkit agent at all.

Verified against a live agent — registration, the PAM conversation, retry
after a rejected attempt, and cancellation. Behaviours found by tracing that
the component now documents:

  * registration is ASYNCHRONOUS, so a Component.onCompleted check reports a
    false failure while a change handler cannot see a total failure at all
    (a failed registration never changes the property) — hence the deadline
  * a flow arrives with isResponseRequired false and an empty prompt, so the
    field is still disabled when the window first becomes visible and the
    re-focus on that transition is load bearing
  * concurrent requests SUPERSEDE rather than queue, orphaning the older one.
    Cancelling it from QML trips "QObject::connect(AuthFlow, PolkitAgentImpl):
    invalid nullptr parameter" upstream and costs the live prompt as well, so
    it is deliberately left alone
  * Identity.id is the raw uid, not unix-user:<name>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud
This commit is contained in:
2026-09-01 22:35:23 +02:00
co-authored by Claude Opus 5
parent 2bf71494f4
commit 05d26e386f
5 changed files with 546 additions and 1 deletions
@@ -0,0 +1,231 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Polkit
import qs.HyprChrome.Theme
// Polkit authentication agent for the hyprchrome shell.
//
// Instantiating PolkitAgent IS the registration — it registers a listener for
// this logind session in componentComplete(), so there is nothing to start and
// nothing to call. Two consequences:
//
// * Only ONE agent may hold a session. hyprpolkitagent must not be running
// (hosts/terra/home/hyprland.nix autostart), or registration fails and this
// dialog silently never appears. `isRegistered` is the check.
// * `path` is write-once — the binary refuses a later assignment with
// "cannot change path after it has been set." Set it here or not at all.
//
// Concurrent requests SUPERSEDE each other — they do not queue. Verified
// against a live trace of two simultaneous `pkexec` calls: both logged
// "activating authentication request" back to back, each with its own cookie
// and its own "setting up session", with no wait for the first to finish.
// `agent.flow` simply becomes the newest request.
//
// The consequence is that the earlier request is ORPHANED: its PAM session is
// live and polkit is still waiting on it, but nothing in QML can reach it any
// more, so its caller hangs until it gives up and polkit cancels — which
// surfaces as quickshell's "the cancelled request was not found in the queue".
// This dialog therefore shows the newest request and loses the older one. See
// the flow-change handler below; fixing it properly means holding superseded
// flows in QML and re-presenting them, which is only worth doing if concurrent
// authorization prompts turn out to happen in practice.
//
// Everything below re-latches per flow instead of caching it.
//
// The visual core lives in PolkitPromptContent so it can be rendered headlessly
// and staged in DebugWindow; this file owns the agent, the surface and focus.
Scope {
id: root
// Where the flow's identity list is currently pointed. Held here rather
// than read back off the flow because the content addresses identities by
// index and AuthFlow addresses them by object.
readonly property var flow: agent.flow
// Whether this shell actually holds the session's agent. Exposed because
// failure is invisible from the outside: an unregistered agent simply never
// shows a dialog, which looks exactly like "no one asked for authorization".
readonly property alias registered: agent.isRegistered
// Reset per REQUEST, not per window show.
//
// A second request supersedes the first by swapping `flow` while the dialog
// is already up, so the window never hides in between. Keying the reset off
// the surface's visibility therefore skips that swap entirely and the new
// request inherits whatever was typed for the old one — a password entered
// for one action left sitting in the box for a different action. The flow
// object changing is the event that actually means "new request".
// Do NOT cancel the superseded flow here. It is tempting — a superseded
// request is unreachable but still live, so its caller hangs until killed,
// and cancelling would at least fail it fast. Tried, and it makes things
// strictly worse: cancelling a flow that is no longer the agent's active
// one tears down state the CURRENT request still needs, and quickshell then
// logs
//
// QObject::connect(AuthFlow, PolkitAgentImpl): invalid nullptr parameter
//
// leaving the live request with a broken agent and no dialog at all. So the
// superseded request is dismissed and the one the user can actually see
// never appears. Leaving it orphaned costs one hung caller; cancelling it
// costs the prompt as well.
onFlowChanged: {
if (root.flow) {
content.clearResponse();
content.focusInput();
}
}
function identityIndex(flow) {
if (!flow || !flow.selectedIdentity)
return 0;
for (let i = 0; i < flow.identities.length; i++) {
if (flow.identities[i] === flow.selectedIdentity)
return i;
}
return 0;
}
PolkitAgent {
id: agent
// Default is /org/quickshell/PolkitAgent; named explicitly because it
// cannot be changed after startup and a second shell would collide.
path: "/org/quickshell/PolkitAgent"
onIsRegisteredChanged: {
if (agent.isRegistered)
console.info("polkit: agent registered at", agent.path);
else
console.warn("polkit: agent lost its registration — this session now has no polkit agent");
}
}
// Registration is ASYNCHRONOUS. It is started in the agent's
// componentComplete but only lands a DBus round trip later — measured at
// under 250ms here, still false at Component.onCompleted. So neither an
// immediate check nor the change handler above can report a total failure:
// an agent that never registers stays false from construction onward and
// changes nothing, which is silence rather than an error. Hence a deadline.
//
// Hot reload is fine: quickshell hands the listener to the new generation
// ("taking over listener from previous generation") and isRegistered goes
// true again, verified on a live reload.
//
// Do NOT turn this into a rebuild-and-retry loop. Tried, with the agent in
// a Loader so a fresh one could be constructed. It cannot work: the subject
// polkit means is the SESSION, this process already holds a listener for
// it, and so every rebuilt agent fails identically with
//
// ...PolicyKit1.Error.Failed:
// An authentication agent already exists for the given subject
//
// Nothing QML can do releases that listener. The one time registration did
// fail across a reload, the cause was upstream state already corrupted by
// cancelling a superseded flow (see the flow handler above) — not the
// reload itself, and not something a retry would have recovered.
Timer {
interval: 2000
running: true
onTriggered: {
if (!agent.isRegistered)
console.warn("polkit: agent still unregistered after 2s — another agent (hyprpolkitagent, polkit-gnome, cosmic-osd) is probably holding this session");
}
}
PanelWindow {
id: win
// isCompleted is checked as well as null: the flow reports its terminal
// state before the agent drops it, and the dialog should not linger for
// those frames showing a request that has already been decided.
visible: root.flow !== null && !root.flow.isCompleted
WlrLayershell.layer: WlrLayer.Overlay
// A real modal — unlike the rest of the rail, this one must take the
// keyboard, or the password goes to whatever window was focused.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
color: Theme.textAlpha(0)
anchors {
top: true
left: true
right: true
bottom: true
}
// Scrim. No click-to-dismiss: a polkit request is answered or
// explicitly cancelled, and losing one to a stray click on the
// wallpaper would leave the caller waiting with no visible reason.
Rectangle {
anchors.fill: parent
color: Theme.surface
opacity: 0.72
}
PolkitPromptContent {
id: content
anchors.centerIn: parent
width: 520
message: root.flow ? root.flow.message : ""
actionId: root.flow ? root.flow.actionId : ""
iconName: root.flow ? root.flow.iconName : ""
identities: root.flow ? root.flow.identities : []
selectedIdentity: root.identityIndex(root.flow)
responseRequired: root.flow ? root.flow.isResponseRequired : false
inputPrompt: root.flow ? root.flow.inputPrompt : ""
responseVisible: root.flow ? root.flow.responseVisible : false
supplementaryMessage: root.flow ? root.flow.supplementaryMessage : ""
supplementaryIsError: root.flow ? root.flow.supplementaryIsError : false
failed: root.flow ? root.flow.failed : false
onSubmitted: value => {
if (root.flow)
root.flow.submit(value);
}
onCancelled: {
if (root.flow)
root.flow.cancelAuthenticationRequest();
}
// AuthFlow refuses a null identity, so the index is bounds-checked
// here rather than trusting the view.
onIdentityRequested: index => {
if (root.flow && index >= 0 && index < root.flow.identities.length)
root.flow.selectedIdentity = root.flow.identities[index];
}
}
// Wipe the box on a rejected attempt. `failed` flags the attempt, not
// the request — polkit lets PAM retry, and the flow stays live with a
// fresh prompt, so the field has to be cleared without closing.
Connections {
target: root.flow
enabled: root.flow !== null
function onFailedChanged() {
if (root.flow.failed)
content.clearResponse();
}
// Re-focus when the conversation asks for something. This is load
// bearing, not defensive: a flow arrives with isResponseRequired
// FALSE and an empty inputPrompt — PAM has not asked yet — so the
// window becomes visible while the field is still disabled, and the
// focusInput() below it cannot land. The prompt shows up a moment
// later, and that is the edge that must take the keyboard. The same
// handler covers a second factor and a post-failure retry.
function onIsResponseRequiredChanged() {
if (root.flow.isResponseRequired)
content.focusInput();
}
}
}
}
@@ -0,0 +1,271 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import qs.HyprChrome.Theme
import qs.HyprChrome.Widgets.Bar.Panels
// Headlessly renderable visual core of the polkit authentication prompt.
//
// Nothing here imports Quickshell.Services.Polkit: every field an AuthFlow
// exposes arrives as a plain property and every action leaves as a signal, so
// the whole dialog can be rendered offscreen (tools/quickshell-preview) and
// staged in DebugWindow without a real authorization request. PolkitPrompt.qml
// owns the agent and does the mapping.
//
// `identities` is read structurally — each entry only needs `displayName` — so
// the adapter can hand over the flow's QList<Identity*> unchanged while the
// headless test passes plain JS objects.
Item {
id: root
// ---- flow state, mirrored ----
property string message: ""
property string actionId: ""
property string iconName: ""
property bool showIcon: true
// Who may authenticate. One entry is the common case and renders as a
// plain line; the picker only appears when polkit actually offers a
// choice (a user in several admin groups, or root plus wheel).
property var identities: []
property int selectedIdentity: 0
// PAM conversation. `responseVisible` is polkit's echo flag — it is NOT
// always false: a smartcard PIN prompt or a security-question stack asks
// for echoed input, and masking those makes the prompt unusable.
property bool responseRequired: false
property string inputPrompt: ""
property bool responseVisible: false
// pam_info / pam_error text, and whether the last attempt was rejected.
property string supplementaryMessage: ""
property bool supplementaryIsError: false
property bool failed: false
property alias response: responseInput.text
signal submitted(string value)
signal cancelled
signal identityRequested(int index)
function focusInput() { responseInput.forceActiveFocus(); }
function clearResponse() { responseInput.text = ""; }
implicitWidth: 520
implicitHeight: panel.implicitHeight
// Escape reaches here by propagating up the focus chain from the TextInput,
// which does not consume it — so cancelling works whether or not the input
// currently has focus.
Keys.onEscapePressed: event => {
root.cancelled();
event.accepted = true;
}
component MicroText: Text {
color: Theme.muted
font.family: Theme.microFont
font.pixelSize: 8
font.letterSpacing: 0.9
elide: Text.ElideRight
}
BarPanel {
id: panel
width: root.width
panelId: "PKT"
title: "AUTHORIZATION REQUIRED"
// The action id is the one piece that says WHAT is being authorized
// independently of the (localizable, often vague) message.
meta: root.actionId
chamfer: 16
// A modal, not a rail panel: clicking the body must reach the input
// rather than collapse the dialog out from under it.
expanded: true
toggleOnClick: false
// Collapsed rendering is never shown here, but BarPanel keeps both
// slots instantiated, so the summary stays bound to the same truth.
summary: MicroText {
text: root.inputPrompt
color: Theme.text
}
// Sized like every other BarPanel body: the slot decides the width and
// the layout's implicitHeight becomes its height, so a wrapped message
// or an extra pam_info line grows the panel instead of being clipped.
ColumnLayout {
width: parent.width
spacing: 10
// ---- what is being asked ----
RowLayout {
Layout.fillWidth: true
spacing: 10
IconImage {
visible: root.showIcon && root.iconName !== ""
implicitSize: 32
source: root.showIcon && root.iconName !== ""
? Quickshell.iconPath(root.iconName, "dialog-password")
: ""
}
Text {
Layout.fillWidth: true
wrapMode: Text.Wrap
text: root.message
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 13
font.letterSpacing: 0.6
}
}
// ---- identity ----
// Single identity: stated, not offered. Several: chips, because a
// combo box would be the only QtQuick.Controls widget in the rail.
MicroText {
Layout.fillWidth: true
visible: root.identities.length === 1
text: "AS " + (root.identities.length === 1
? root.identities[0].displayName : "")
}
Flow {
Layout.fillWidth: true
visible: root.identities.length > 1
spacing: 6
Repeater {
model: root.identities
Rectangle {
id: chip
required property int index
required property var modelData
readonly property bool current: chip.index === root.selectedIdentity
width: chipLabel.implicitWidth + 14
height: chipLabel.implicitHeight + 8
color: chip.current ? Theme.accent : "transparent"
border.width: 1
border.color: chip.current ? Theme.accent : Theme.disabled
Text {
id: chipLabel
anchors.centerIn: parent
text: chip.modelData.displayName
color: chip.current ? Theme.surface : Theme.muted
font.family: Theme.microFont
font.pixelSize: 9
font.letterSpacing: 0.8
}
MouseArea {
anchors.fill: parent
onClicked: root.identityRequested(chip.index)
}
}
}
}
// ---- the conversation ----
Rectangle {
Layout.fillWidth: true
implicitHeight: 34
color: Theme.selection
border.width: 1
border.color: root.failed ? Theme.hot : Theme.hair
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 10
Text {
text: ">_"
color: root.responseRequired ? Theme.accent : Theme.disabled
font.family: Theme.displayFont
font.pixelSize: 15
font.bold: true
}
TextInput {
id: responseInput
Layout.fillWidth: true
Layout.fillHeight: true
verticalAlignment: TextInput.AlignVCenter
enabled: root.responseRequired
color: Theme.text
selectionColor: Theme.accent
selectedTextColor: Theme.surface
font.family: Theme.displayFont
font.pixelSize: 14
font.letterSpacing: 1
clip: true
echoMode: root.responseVisible
? TextInput.Normal : TextInput.Password
passwordCharacter: "▪"
// Qt reveals the last typed character for a moment by
// default. On a screen-visible layer-shell overlay
// that is a shoulder-surfing hole, so: never.
passwordMaskDelay: 0
onAccepted: {
if (root.responseRequired)
root.submitted(responseInput.text);
}
// Placeholder: TextInput has none of its own, and the
// PAM prompt ("Password:", "PIN:") is the only label
// this field gets.
Text {
anchors.verticalCenter: parent.verticalCenter
visible: responseInput.text.length === 0
text: root.inputPrompt
color: Theme.disabled
font: responseInput.font
}
}
}
}
// ---- pam_info / pam_error ----
Text {
Layout.fillWidth: true
visible: root.supplementaryMessage !== ""
wrapMode: Text.Wrap
text: root.supplementaryMessage
color: root.supplementaryIsError ? Theme.hot : Theme.muted
font.family: Theme.microFont
font.pixelSize: 9
font.letterSpacing: 0.7
}
// ---- key hints ----
RowLayout {
Layout.fillWidth: true
spacing: 14
MicroText { text: "ENTER AUTHENTICATE" }
MicroText { text: "ESC CANCEL" }
Item { Layout.fillWidth: true }
MicroText {
text: root.responseVisible ? "ECHO ON" : ""
color: Theme.hot
}
}
}
}
}
+6
View File
@@ -15,6 +15,7 @@ import qs.HyprChrome.Widgets
import qs.HyprChrome.Widgets.Bar.Debug import qs.HyprChrome.Widgets.Bar.Debug
import qs.HyprChrome.Widgets.Bar.Host import qs.HyprChrome.Widgets.Bar.Host
import qs.HyprChrome.Widgets.Bar.Vitals import qs.HyprChrome.Widgets.Bar.Vitals
import qs.HyprChrome.Widgets.Polkit
Scope { Scope {
// Dense multi-monitor status rail; visual core is headlessly renderable. // Dense multi-monitor status rail; visual core is headlessly renderable.
@@ -34,6 +35,11 @@ Scope {
CyberDock {} // 11 — cyberpunk bottom cartridge dock CyberDock {} // 11 — cyberpunk bottom cartridge dock
Notifications {} Notifications {}
// Polkit authentication agent. Registers for this logind session on
// creation, so it replaces hyprpolkitagent rather than coexisting with it
// — only one agent may hold a session (see hosts/terra/home/hyprland.nix).
PolkitPrompt {}
VolumeOsd {} VolumeOsd {}
// Host vitals HUD — toggle with SUPER CTRL V. // Host vitals HUD — toggle with SUPER CTRL V.
@@ -0,0 +1,33 @@
import QtQuick
import qs.HyprChrome.Widgets.Polkit
// Offscreen render of the polkit prompt with a failed first attempt and two
// eligible identities — the state that exercises every optional element at
// once (picker, pam_error text, rejected-attempt border).
//
// ./tools/quickshell-preview/render.sh \
// tests/PolkitPromptHeadless.qml \
// .artifacts/quickshell-preview/polkit-prompt.png 560 320
PolkitPromptContent {
width: 520
message: "Authentication is required to install or remove software"
actionId: "org.freedesktop.packagekit.package-install"
iconName: "system-software-install"
showIcon: false
identities: [
{ id: "1000", displayName: "darman", isGroup: false },
{ id: "0", displayName: "root", isGroup: false }
]
selectedIdentity: 0
responseRequired: true
inputPrompt: "Password:"
responseVisible: false
response: "hunter2"
supplementaryMessage: "Authentication failure. 2 attempts remaining."
supplementaryIsError: true
failed: true
}
+5 -1
View File
@@ -310,7 +310,11 @@ in
"hyprland.start" "hyprland.start"
(lua '' (lua ''
function() function()
hl.exec_cmd("systemctl --user start hyprpolkitagent") -- No polkit agent is started here: quickshell registers one
-- itself (HyprChrome/Widgets/Polkit), and a session admits only
-- one. The hyprpolkitagent line this replaces had been dead for
-- a while anyway the unit was never installed, so the start
-- failed silently and the session ran with no agent at all.
hl.exec_cmd("cosmic-settings-daemon") hl.exec_cmd("cosmic-settings-daemon")
hl.exec_cmd("quickshell") hl.exec_cmd("quickshell")
hl.exec_cmd("alacritty", { workspace = "special:terminal silent" }) hl.exec_cmd("alacritty", { workspace = "special:terminal silent" })