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
232 lines
10 KiB
QML
232 lines
10 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|
|
}
|