Files
homelab/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml
T
darmanandClaude Opus 5 05d26e386f 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
2026-09-01 22:35:23 +02:00

272 lines
9.8 KiB
QML

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
}
}
}
}
}