PolkitPanel replaces BarPanel as the dialog's chrome. Deliberately not a
subclass or a fork: most of BarPanel is density machinery — summary slot,
animated height, state pair, transitions — that a modal never uses, and
inheriting it would tie the dialog's look to a component whose real job is the
rail, so every restyle here would have to be justified against the panels up
there. It keeps the shell's silhouette (cut corners with detached accent caps,
the accent rules, the header strip) and drops the rail's tick decoration.
ESC closes the rail. The bar had no keyboard focus at all, so this adds it,
gated by the shell rather than left to the compositor to arbitrate between two
exclusive surfaces — that resolves by stacking and would invert silently the
day the layers change:
grabsKeyboard: shell.expanded && !polkit.prompting
so ESC dismisses the prompt while one is open and closes the rail afterwards.
Verified by instrumenting the handoff: expanded -> true, prompt open -> false,
prompt dismissed -> true, collapsed -> false. Note the rail now takes EXCLUSIVE
keyboard focus while expanded, which is the cost of answering a keypress the
user has not aimed at anything.
The dialog sits a third of the way down rather than centred, panel centre on
the third so it grows symmetrically as the message wraps, floored at a margin
so a tall prompt on a short output cannot be pushed off the top.
Also carries the backdrop tuning: dim 0.75 -> 0.65, gridOpacity 0.15 -> 0.10,
crossOpacity 0.45 -> 0.15, now that the scrim is used by the prompt as well as
the rail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud
256 lines
12 KiB
QML
256 lines
12 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
|
|
|
|
// A third of the way down rather than centred: a password prompt
|
|
// reads better above the middle, and on a tall output dead-centre
|
|
// puts it below the natural resting line of the eye.
|
|
//
|
|
// The panel's own CENTRE lands on the third, so the dialog grows
|
|
// symmetrically about that line as the message wraps or a pam_info
|
|
// line appears. Floored at the same margin the width leaves, so a
|
|
// tall prompt on a short output cannot be pushed off the top.
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
y: Math.max(32, Math.round(parent.height / 3 - height / 2))
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|