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();
}
}
}
}