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:
@@ -0,0 +1,271 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user