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
272 lines
9.8 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|