Files
homelab/dotfiles/quickshell/HyprChrome/Widgets/Askpass/AskpassPrompt.qml
T
2026-09-18 20:39:06 +02:00

183 lines
6.6 KiB
QML

pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.HyprChrome.Theme
import qs.HyprChrome.Widgets.Polkit
// GUI password prompt for `sudo -A`, reusing the polkit dialog.
//
// sudo does NOT speak polkit — it is setuid + PAM reading your tty, and no
// sudoers option bridges the two. What it does support is an ASKPASS helper: a
// program it runs to obtain the password, which prints it on stdout. So this is
// not the polkit agent serving sudo; it is a second, separate path that happens
// to render the same dialog.
//
// Flow, driven by the helper in home.nix (`qs-askpass`):
//
// sudo -A
// -> qs-askpass makes a 0600 fifo under XDG_RUNTIME_DIR
// -> qs ipc call askpass prompt "<prompt>" "<fifo>" (returns at once)
// -> this dialog opens, user types
// -> a one-line writer is started here, secret written to its STDIN
// -> qs-askpass reads the fifo and prints the secret on stdout
// -> sudo reads it
//
// The secret travels on a pipe the whole way. It is never an argument and never
// an environment variable, so it does not appear in /proc for any process — the
// fifo PATH is in argv, which is not secret. It does cross more process
// boundaries than the polkit path, where the password stays inside the PAM
// conversation; that is the inherent cost of askpass, not of this design.
//
// Cancelling answers with an empty line, so the helper reads nothing, exits
// non-zero, and sudo aborts rather than burning a retry on a blank password.
Scope {
id: root
// Which output to appear on; the shell puts it on the focused monitor.
property var screen: null
// The fifo the helper is blocked reading. Non-empty means a request is in
// flight, which is exactly what "a prompt is open" means here.
property string fifoPath: ""
property string promptText: ""
property bool failed: false
readonly property bool active: root.fifoPath !== ""
// Held only between submit and the writer process actually starting: a
// Process cannot be written to before it is running.
property string pendingSecret: ""
IpcHandler {
target: "askpass"
// Called by qs-askpass. Returns immediately — the helper blocks on the
// fifo, not on this call, because an IpcHandler function runs on the
// QML thread and blocking here would freeze the whole shell.
function prompt(message: string, fifo: string): string {
if (root.active)
return "busy";
root.promptText = message === "" ? "Password:" : message;
root.fifoPath = fifo;
root.failed = false;
return "ok";
}
// So a helper that times out can take the dialog down with it rather
// than leaving it on screen with nothing listening.
function cancel(): string {
root.dismiss();
return "ok";
}
}
// Cancelling answers with an EMPTY line rather than by closing silently:
// the helper then reads zero bytes and exits non-zero, so sudo aborts
// instead of spending a retry on a blank password.
function dismiss() {
root.respond("");
}
function submit(secret) {
root.respond(secret);
}
// The writer reads ONE LINE and exits; it does not wait for EOF.
//
// The obvious version — `cat > fifo`, write the secret, then close stdin by
// setting stdinEnabled false — does not terminate. Measured: the secret
// arrives intact but `cat` never sees EOF, so the fifo is never closed and
// the helper blocks until its timeout. sudo would hang after you typed.
//
// A single `read` needs no EOF at all: the trailing newline ends it, the
// shell writes what it got and exits, and THAT close is what gives the
// helper its EOF. `IFS=` keeps leading and trailing whitespace, `-r` keeps
// backslashes, and the secret still travels on stdin rather than in argv.
function respond(secret) {
if (!root.active)
return;
root.pendingSecret = secret + "\n";
writer.command = ["sh", "-c", "IFS= read -r line; printf %s \"$line\" > \"$1\"", "sh", root.fifoPath];
writer.running = true;
root.fifoPath = "";
}
// Opening a fifo for writing BLOCKS until a reader attaches, which is why
// this is a subprocess rather than a FileView: the helper's `cat` is that
// reader, and blocking the QML thread on it would freeze the shell.
Process {
id: writer
stdinEnabled: true
// Written on `started`, not at respond() time: a Process has no stdin
// to write to until it is actually running.
onStarted: {
writer.write(root.pendingSecret);
root.pendingSecret = "";
}
}
PanelWindow {
id: win
screen: root.screen
visible: root.active
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
color: Theme.textAlpha(0)
anchors {
top: true
left: true
right: true
bottom: true
}
// No click-off dismissal, for the same reason the polkit dialog has
// none: something is blocked waiting on the answer, and losing it to a
// stray click would leave sudo hanging with no visible cause.
PolkitPromptContent {
id: content
anchors.horizontalCenter: parent.horizontalCenter
y: Math.max(32, Math.round(parent.height / 3 - height / 2))
width: 520
// Deliberately the polkit dialog's own content component: this is a
// password prompt with the same shape, and keeping one means a
// restyle of PolkitPanel covers both. `identities` stays empty —
// sudo offers no choice of who authenticates — which hides the
// picker and the "AS" line on its own.
message: "Authentication is required to run a command as another user"
actionId: "sudo"
iconName: ""
showIcon: false
identities: []
responseRequired: true
inputPrompt: root.promptText
responseVisible: false
failed: root.failed
onSubmitted: value => root.submit(value)
onCancelled: root.dismiss()
}
onVisibleChanged: {
if (win.visible) {
content.clearResponse();
content.focusInput();
}
}
}
}