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,231 @@
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
// isCompleted is checked as well as null: the flow reports its terminal
|
||||
// state before the agent drops it, and the dialog should not linger for
|
||||
// those frames showing a request that has already been decided.
|
||||
visible: root.flow !== null && !root.flow.isCompleted
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Scrim. No click-to-dismiss: 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.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.surface
|
||||
opacity: 0.72
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import qs.HyprChrome.Widgets
|
||||
import qs.HyprChrome.Widgets.Bar.Debug
|
||||
import qs.HyprChrome.Widgets.Bar.Host
|
||||
import qs.HyprChrome.Widgets.Bar.Vitals
|
||||
import qs.HyprChrome.Widgets.Polkit
|
||||
|
||||
Scope {
|
||||
// Dense multi-monitor status rail; visual core is headlessly renderable.
|
||||
@@ -34,6 +35,11 @@ Scope {
|
||||
CyberDock {} // 11 — cyberpunk bottom cartridge dock
|
||||
|
||||
Notifications {}
|
||||
|
||||
// Polkit authentication agent. Registers for this logind session on
|
||||
// creation, so it replaces hyprpolkitagent rather than coexisting with it
|
||||
// — only one agent may hold a session (see hosts/terra/home/hyprland.nix).
|
||||
PolkitPrompt {}
|
||||
VolumeOsd {}
|
||||
|
||||
// Host vitals HUD — toggle with SUPER CTRL V.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Widgets.Polkit
|
||||
|
||||
// Offscreen render of the polkit prompt with a failed first attempt and two
|
||||
// eligible identities — the state that exercises every optional element at
|
||||
// once (picker, pam_error text, rejected-attempt border).
|
||||
//
|
||||
// ./tools/quickshell-preview/render.sh \
|
||||
// tests/PolkitPromptHeadless.qml \
|
||||
// .artifacts/quickshell-preview/polkit-prompt.png 560 320
|
||||
PolkitPromptContent {
|
||||
width: 520
|
||||
|
||||
message: "Authentication is required to install or remove software"
|
||||
actionId: "org.freedesktop.packagekit.package-install"
|
||||
iconName: "system-software-install"
|
||||
showIcon: false
|
||||
|
||||
identities: [
|
||||
{ id: "1000", displayName: "darman", isGroup: false },
|
||||
{ id: "0", displayName: "root", isGroup: false }
|
||||
]
|
||||
selectedIdentity: 0
|
||||
|
||||
responseRequired: true
|
||||
inputPrompt: "Password:"
|
||||
responseVisible: false
|
||||
response: "hunter2"
|
||||
|
||||
supplementaryMessage: "Authentication failure. 2 attempts remaining."
|
||||
supplementaryIsError: true
|
||||
failed: true
|
||||
}
|
||||
@@ -310,7 +310,11 @@ in
|
||||
"hyprland.start"
|
||||
(lua ''
|
||||
function()
|
||||
hl.exec_cmd("systemctl --user start hyprpolkitagent")
|
||||
-- No polkit agent is started here: quickshell registers one
|
||||
-- itself (HyprChrome/Widgets/Polkit), and a session admits only
|
||||
-- one. The hyprpolkitagent line this replaces had been dead for
|
||||
-- a while anyway — the unit was never installed, so the start
|
||||
-- failed silently and the session ran with no agent at all.
|
||||
hl.exec_cmd("cosmic-settings-daemon")
|
||||
hl.exec_cmd("quickshell")
|
||||
hl.exec_cmd("alacritty", { workspace = "special:terminal silent" })
|
||||
|
||||
Reference in New Issue
Block a user