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 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(); } } } }