Files
homelab/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml
T
darmanandClaude Opus 5 38608c4008 feat(quickshell): give the shell the polkit agent and one shared scrim
Moves PolkitPrompt from shell.qml into HyprChromeShell. Whether a prompt is
open is shell state by the same rule as the screen and the layer pair: two
surfaces read it.

The prompt no longer carries a backdrop of its own. There is one
ChromeBackdrop per output and a prompt raises them all, so a prompt over an
already-expanded rail reuses the scrim that is there rather than stacking a
second one on it, and a prompt over a collapsed rail expands that same scrim
from its bar-height band to the whole output.

The layer pair now keys off `scrimUp` (expanded OR prompting) rather than off
the density, which keeps bar and backdrop exactly one level apart in every
state. A prompt over a collapsed rail raises both: BACKGROUND sits under
ordinary windows so a scrim there dims nothing, and the bar has to stay one
above the scrim or the shell dims its own chrome. The rail is raised but
stays collapsed — its layer answers to the scrim, its height to `expanded`.

Outputs the rail does not live on get a scrim only while a prompt is up; a
modal that dims one monitor and leaves the others lit does not read as modal.
Expanding the rail still dims only the rail's screen, which is the existing
behaviour and the right one.

The dialog follows Hyprland.focusedMonitor rather than the rail's screen — a
password prompt belongs where the user is looking — matched by name against
Quickshell.screens, falling back to the rail's screen rather than to nothing.

SUPER A is frozen while a prompt is open, and dropped rather than queued, so
the rail does not spring open the moment the dialog goes.

Verified on two monitors via hyprctl layers, both densities, plus the toggle
block with an odd number of presses (two cancel out and prove nothing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud
2026-09-01 23:24:05 +02:00

247 lines
11 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
// Which output the dialog appears on. Driven by the shell, which puts it on
// the focused monitor rather than on the rail's — a password prompt belongs
// where the user is looking. Left unset it falls back to whatever screen
// quickshell picks, which is right for a single-monitor session.
property var screen: null
// 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
// Whether a request is being presented. Both surfaces read it, so it is
// decided once here rather than each deriving it — the scrim and the dialog
// must come and go on the same frame.
//
// isCompleted is checked as well as null: the flow reports its terminal
// state before the agent drops it, and neither surface should linger for
// those frames over a request that has already been decided.
readonly property bool prompting: root.flow !== null && !root.flow.isCompleted
// 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");
}
}
// No scrim of its own. The shell owns the single ChromeBackdrop and raises
// it for either cause — an expanded rail or an open prompt — so a prompt
// arriving over an already-expanded rail reuses the scrim that is already
// there instead of stacking a second one on top of it. `prompting` above is
// what the shell reads to decide. See HyprChromeShell.
PanelWindow {
id: win
screen: root.screen
visible: root.prompting
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
}
// No scrim here — ChromeBackdrop above draws it. This surface stays
// transparent but unmasked, so it still swallows clicks across the
// whole output: 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.
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();
}
}
}
}