feat(terra): replace tuigreet with a quickshell greeter

greetd now runs a throwaway Hyprland hosting dotfiles/quickshell/greeter.qml,
configured per host via homelab.greeter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:34:10 +02:00
co-authored by Claude Opus 5
parent 4adb59808f
commit f3dddc9150
10 changed files with 857 additions and 6 deletions
+6
View File
@@ -210,3 +210,9 @@ kept its ssh host key. Run it after ANY change to the kexec paths.
- **`kexec-local` stages on `/var/tmp`, not `/tmp`**: `kexec-run.sh` appends a fresh cpio
to `kexec/initrd` in place and execs binaries from that dir, so a size-capped or
`noexec` tmpfs gives a half-written initrd or a bare "Permission denied".
- **terra's greeter is a throwaway Hyprland running `dotfiles/quickshell/greeter.qml`**
(`services/desktop/quickshell-greeter.nix`). It must exit after login or greetd never
starts the session, and with a Lua config `hyprctl dispatch exit` is REJECTED — it needs
`hyprctl dispatch 'hl.dsp.exit()'`. Test the flow without touching the real greetd by
running greetd's `fakegreet "qs -p …/greeter.qml"` inside a nested Hyprland
(user `user`, password `password`, then answer `9`).
+2
View File
@@ -100,6 +100,8 @@ on it, hence the index loops in `HyprChromeShell`.
and its panels, with `Bar/Panels/BarPanel.qml` the chamfered chrome they all
extend; `Widgets/Polkit/` is the authentication agent and its dialog;
`Widgets/Launcher/` is the primary application launcher (`SUPER_L`);
`Widgets/Greeter/` is the greetd login screen, run standalone via `greeter.qml`
(see `services/desktop/quickshell-greeter.nix`), NOT part of `shell.qml`;
`Theme/Theme.qml` is this tree's palette singleton. `DebugWindow.qml` stages a
single widget on the secondary monitor for eyeballing it in isolation.
@@ -0,0 +1,200 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Greetd
import qs.HyprChrome.Widgets
// greetd greeter: backdrop on every output, login panel on the primary one.
// Configured through env set by services/desktop/quickshell-greeter.nix.
Scope {
id: root
readonly property string primaryName: Quickshell.env("QS_GREETER_OUTPUT") ?? ""
readonly property string defaultUser: Quickshell.env("QS_GREETER_USER") ?? ""
readonly property string sessionCommand: Quickshell.env("QS_GREETER_SESSION") || "start-hyprland"
readonly property string sessionName: Quickshell.env("QS_GREETER_SESSION_NAME") || "hyprland"
readonly property string hostName: Quickshell.env("QS_GREETER_HOST") || "localhost"
readonly property var primaryScreen: {
const screens = Quickshell.screens;
if (screens.length === 0)
return null;
for (let i = 0; i < screens.length; i++) {
if (screens[i].name === root.primaryName)
return screens[i];
}
return screens[0];
}
// Typed password, held until PAM's first secret prompt arrives.
property string pendingSecret: ""
// PAM asked a follow-up (OTP etc.) that the user must answer directly.
property bool awaitingResponse: false
property bool busy: false
property bool launching: false
function setMessage(text, isError) {
content.message = text;
content.messageIsError = isError;
}
function resetToPassword() {
root.pendingSecret = "";
root.awaitingResponse = false;
root.busy = false;
content.inputPrompt = "";
content.responseVisible = false;
content.clearSecret();
content.focusSecret();
}
function submit(user, secret) {
if (!Greetd.available) {
root.setMessage("greetd socket unavailable", true);
return;
}
content.failed = false;
if (root.awaitingResponse) {
root.awaitingResponse = false;
root.busy = true;
content.clearSecret();
Greetd.respond(secret);
return;
}
if (Greetd.state !== GreetdState.Inactive)
return;
root.setMessage("", false);
root.pendingSecret = secret;
root.busy = true;
Greetd.createSession(user);
}
Connections {
target: Greetd
function onAuthMessage(message, error, responseRequired, echoResponse) {
if (!responseRequired) {
root.setMessage(message, error);
return;
}
// First hidden prompt is the password already typed.
if (root.pendingSecret !== "" && !echoResponse) {
const secret = root.pendingSecret;
root.pendingSecret = "";
Greetd.respond(secret);
return;
}
root.busy = false;
root.awaitingResponse = true;
content.inputPrompt = message.replace(/:\s*$/, "");
content.responseVisible = echoResponse;
content.clearSecret();
content.focusSecret();
}
function onAuthFailure(message) {
root.resetToPassword();
content.failed = true;
root.setMessage(message || "authentication failed", true);
}
function onError(error) {
root.resetToPassword();
root.setMessage(error, true);
}
function onReadyToLaunch() {
root.setMessage("starting " + root.sessionName, false);
root.launching = true;
}
}
// Fade out first: greetd wants the greeter gone promptly after launch().
Timer {
running: root.launching
interval: 220
onTriggered: Greetd.launch([root.sessionCommand])
}
Variants {
model: Quickshell.screens
ChromeBackdrop {
required property var modelData
screen: modelData
active: true
dim: 1
wlrLayer: WlrLayer.Background
}
}
PanelWindow {
screen: root.primaryScreen
visible: root.primaryScreen !== null
WlrLayershell.namespace: "hyprchrome-greeter"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
LoginContent {
id: content
width: 640
anchors.horizontalCenter: parent.horizontalCenter
y: Math.max(32, Math.round(parent.height * 0.42 - height / 2)) + (1 - opacity) * 12
opacity: root.launching ? 0 : 1
Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
hostName: root.hostName
sessionName: root.sessionName
user: root.defaultUser
busy: root.busy || root.launching
onSubmitted: (user, secret) => root.submit(user, secret)
// A different user invalidates a half-finished conversation.
onUserEdited: {
if (Greetd.state !== GreetdState.Inactive)
Greetd.cancelSession();
root.resetToPassword();
content.focusUser();
}
onPowerRequested: action => Quickshell.execDetached(["systemctl", action])
Component.onCompleted: {
if (root.defaultUser !== "")
content.focusSecret();
else
content.focusUser();
}
}
Timer {
running: true
repeat: true
interval: 1000
triggeredOnStart: true
onTriggered: content.now = new Date()
}
}
}
@@ -0,0 +1,304 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import qs.HyprChrome.Theme
// Headless visual core of the login greeter; Greeter.qml owns greetd.
Item {
id: root
property string hostName: ""
property string sessionName: ""
property date now: new Date()
// Label over the secret field. Empty means the plain password step; set
// when PAM asks for something else (OTP, PIN).
property string inputPrompt: ""
property bool responseVisible: false
property string message: ""
property bool messageIsError: false
property bool failed: false
property bool busy: false
property alias user: userInput.text
property alias response: secretInput.text
signal submitted(string user, string response)
signal userEdited
signal powerRequested(string action)
function focusSecret() { secretInput.forceActiveFocus(); }
function focusUser() { userInput.forceActiveFocus(); }
function clearSecret() { secretInput.text = ""; }
implicitWidth: 640
implicitHeight: panel.implicitHeight
function submit() {
if (root.busy)
return;
if (userInput.text.trim() === "") {
root.focusUser();
return;
}
root.submitted(userInput.text.trim(), secretInput.text);
}
component MicroText: Text {
color: Theme.muted
font.family: Theme.microFont
font.pixelSize: 8
font.letterSpacing: 0.9
elide: Text.ElideRight
}
component Field: Rectangle {
id: field
property bool active: false
property bool alert: false
property string glyph: ""
default property alias input: slot.data
Layout.fillWidth: true
implicitHeight: 36
color: Theme.selection
border.width: 1
border.color: field.alert ? Theme.hot : field.active ? Theme.accentAlpha(0.7) : Theme.hair
Behavior on border.color { ColorAnimation { duration: 120 } }
// Focus tick on the left edge.
Rectangle {
width: 2
height: parent.height
color: Theme.accent
visible: field.active
}
Text {
id: glyphText
x: 12
anchors.verticalCenter: parent.verticalCenter
text: field.glyph
color: field.active ? Theme.accent : Theme.disabled
font.family: Theme.displayFont
font.pixelSize: 14
font.bold: true
}
Item {
id: slot
anchors.left: glyphText.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 12
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
component PowerChip: Rectangle {
id: chip
property string label: ""
property string action: ""
width: chipLabel.implicitWidth + 16
height: chipLabel.implicitHeight + 8
color: chipMouse.containsMouse ? Theme.accent : "transparent"
border.width: 1
border.color: chipMouse.containsMouse ? Theme.accent : Theme.disabled
Text {
id: chipLabel
anchors.centerIn: parent
text: chip.label
color: chipMouse.containsMouse ? Theme.surface : Theme.muted
font.family: Theme.microFont
font.pixelSize: 9
font.letterSpacing: 0.8
}
MouseArea {
id: chipMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.powerRequested(chip.action)
}
}
LoginPanel {
id: panel
width: root.width
panelId: "LGN"
title: "SESSION // " + root.hostName.toUpperCase()
meta: "GREETD"
busy: root.busy
ColumnLayout {
width: parent.width
spacing: 16
RowLayout {
Layout.fillWidth: true
spacing: 20
// ---- clock column ----
ColumnLayout {
Layout.preferredWidth: 190
Layout.alignment: Qt.AlignTop
spacing: 4
Text {
text: Qt.formatTime(root.now, "HH:mm")
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 54
font.letterSpacing: 2
}
MicroText {
text: Qt.formatDate(root.now, "ddd dd.MM.yyyy").toUpperCase()
color: Theme.accent
font.pixelSize: 10
}
// Seconds as a segment meter: one cell per five seconds.
Row {
Layout.topMargin: 10
spacing: 3
Repeater {
model: 12
Rectangle {
required property int index
width: 11
height: 5
color: index < Math.floor(root.now.getSeconds() / 5) + 1
? Theme.accent : Theme.raised
}
}
}
MicroText {
Layout.topMargin: 10
text: "SESSION " + root.sessionName.toUpperCase()
}
}
Rectangle {
Layout.fillHeight: true
implicitWidth: 1
color: Theme.textAlpha(0.12)
}
// ---- credentials ----
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: 6
MicroText { text: "OPERATOR" }
Field {
glyph: "@"
active: userInput.activeFocus
TextInput {
id: userInput
anchors.fill: parent
verticalAlignment: TextInput.AlignVCenter
enabled: !root.busy && root.inputPrompt === ""
color: Theme.text
selectionColor: Theme.accent
selectedTextColor: Theme.surface
font.family: Theme.displayFont
font.pixelSize: 14
font.letterSpacing: 1
clip: true
onTextEdited: root.userEdited()
onAccepted: root.focusSecret()
KeyNavigation.tab: secretInput
}
}
MicroText {
Layout.topMargin: 6
text: root.inputPrompt !== "" ? root.inputPrompt.toUpperCase() : "PASSPHRASE"
color: root.inputPrompt !== "" ? Theme.accent : Theme.muted
}
Field {
glyph: ">_"
active: secretInput.activeFocus
alert: root.failed
TextInput {
id: secretInput
anchors.fill: parent
verticalAlignment: TextInput.AlignVCenter
enabled: !root.busy
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: "▪"
passwordMaskDelay: 0
onAccepted: root.submit()
KeyNavigation.backtab: userInput
}
}
// Status line; keeps its height so the panel does not jump.
MicroText {
Layout.fillWidth: true
Layout.topMargin: 4
Layout.preferredHeight: 12
text: root.busy && root.message === "" ? "AUTHENTICATING…" : root.message.toUpperCase()
color: root.messageIsError ? Theme.hot : root.busy ? Theme.accent : Theme.muted
font.pixelSize: 9
}
}
}
// ---- footer ----
Rectangle {
Layout.fillWidth: true
implicitHeight: 1
color: Theme.textAlpha(0.12)
}
RowLayout {
Layout.fillWidth: true
spacing: 14
MicroText { text: "ENTER LOG IN" }
MicroText { text: "TAB SWITCH FIELD" }
Item { Layout.fillWidth: true }
PowerChip { label: "REBOOT"; action: "reboot" }
PowerChip { label: "POWER OFF"; action: "poweroff" }
}
}
}
}
@@ -0,0 +1,231 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import qs.HyprChrome.Theme
// Panel chrome for the login greeter. Same vocabulary as PolkitPanel, mirrored:
// cuts on the OTHER diagonal (top-left, bottom-right), registration brackets
// instead of detached caps, and a segmented accent spine down the left edge.
Item {
id: panel
property string panelId: ""
property string title: ""
property string meta: ""
property int chamfer: 20
property int padding: 18
property int spineWidth: 3
property int spineSegments: 9
// Sweeps the bottom rule while greetd is working.
property bool busy: false
readonly property int headerHeight: 32
readonly property int headerPadding: 12
property real bracketGap: 6
property real bracketArm: 14
readonly property real activeChamfer: Math.max(2, Math.min(panel.chamfer, panel.height / 2 - 1))
default property alias content: body.data
implicitHeight: Math.round(body.y + body.height + panel.padding)
Shape {
id: panelShape
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: Theme.surface
strokeColor: Theme.hair
strokeWidth: 1
startX: panel.activeChamfer; startY: 0
PathLine { x: panelShape.width; y: 0 }
PathLine { x: panelShape.width; y: panelShape.height - panel.activeChamfer }
PathLine { x: panelShape.width - panel.activeChamfer; y: panelShape.height }
PathLine { x: 0; y: panelShape.height }
PathLine { x: 0; y: panel.activeChamfer }
PathLine { x: panel.activeChamfer; y: 0 }
}
// Accent along each cut, inset so it reads as an edge highlight.
ShapePath {
fillColor: "transparent"
strokeColor: Theme.accent
strokeWidth: 2
capStyle: ShapePath.FlatCap
startX: 0; startY: panel.activeChamfer
PathLine { x: panel.activeChamfer; y: 0 }
}
ShapePath {
fillColor: "transparent"
strokeColor: Theme.accent
strokeWidth: 2
capStyle: ShapePath.FlatCap
startX: panelShape.width; startY: panelShape.height - panel.activeChamfer
PathLine { x: panelShape.width - panel.activeChamfer; y: panelShape.height }
}
// Registration brackets on the two square corners, echoing the
// backdrop's crosses.
ShapePath {
fillColor: "transparent"
strokeColor: Theme.muted
strokeWidth: 1
startX: panelShape.width + panel.bracketGap - panel.bracketArm
startY: -panel.bracketGap
PathLine { x: panelShape.width + panel.bracketGap; y: -panel.bracketGap }
PathLine { x: panelShape.width + panel.bracketGap; y: -panel.bracketGap + panel.bracketArm }
}
ShapePath {
fillColor: "transparent"
strokeColor: Theme.muted
strokeWidth: 1
startX: -panel.bracketGap
startY: panelShape.height + panel.bracketGap - panel.bracketArm
PathLine { x: -panel.bracketGap; y: panelShape.height + panel.bracketGap }
PathLine { x: -panel.bracketGap + panel.bracketArm; y: panelShape.height + panel.bracketGap }
}
}
// Segmented spine, below the header.
Column {
x: 0
y: panel.headerHeight + 8
spacing: 3
readonly property real segment: (panel.height - panel.headerHeight - 8 - panel.activeChamfer - 8
- (panel.spineSegments - 1) * spacing) / panel.spineSegments
Repeater {
model: panel.spineSegments
Rectangle {
required property int index
width: panel.spineWidth
height: Math.max(1, parent.segment)
color: Theme.accent
// Fades downward.
opacity: 1 - index / panel.spineSegments * 0.85
}
}
}
// Header: title, meta, slug chip on the right.
Text {
x: panel.activeChamfer + panel.headerPadding
anchors.verticalCenter: header.verticalCenter
text: panel.title
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 12
font.bold: true
font.letterSpacing: 1.4
}
Row {
id: header
anchors.right: parent.right
anchors.rightMargin: panel.headerPadding
y: Math.round((panel.headerHeight - height) / 2)
spacing: 12
Text {
anchors.verticalCenter: slugChip.verticalCenter
visible: panel.meta.length > 0
text: panel.meta
color: Theme.muted
font.family: Theme.microFont
font.pixelSize: 8
font.letterSpacing: 0.7
}
Rectangle {
id: slugChip
width: slugText.implicitWidth + 10
height: slugText.implicitHeight + 4
color: "transparent"
border.width: 1
border.color: Theme.accent
Text {
id: slugText
anchors.centerIn: parent
text: panel.panelId
color: Theme.accent
font.family: Theme.microFont
font.pixelSize: 11
font.bold: true
}
}
}
// Header rule: dashed, not solid, to set it apart from the polkit dialog.
Row {
x: panel.activeChamfer
y: panel.headerHeight
spacing: 3
Repeater {
model: Math.max(0, Math.floor((panel.width - panel.activeChamfer - 1) / 9))
Rectangle {
width: 6
height: 1
color: Theme.textAlpha(0.18)
}
}
}
// Busy sweep along the bottom edge.
Item {
x: 0
y: panel.height - 2
width: panel.width - panel.activeChamfer
height: 2
clip: true
visible: panel.busy
Rectangle {
id: sweep
width: parent.width / 4
height: parent.height
color: Theme.accent
NumberAnimation on x {
running: panel.busy
loops: Animation.Infinite
from: -sweep.width
to: panel.width
duration: 900
easing.type: Easing.InOutQuad
}
}
}
Item {
id: body
x: panel.padding + panel.spineWidth
y: panel.headerHeight + panel.padding
width: Math.max(0, panel.width - panel.padding * 2 - panel.spineWidth)
height: childrenRect.height
}
}
+8
View File
@@ -0,0 +1,8 @@
import Quickshell
import qs.HyprChrome.Widgets.Greeter
// greetd greeter entry point: `qs -p greeter.qml`, as the greeter user. See
// services/desktop/quickshell-greeter.nix.
Scope {
Greeter {}
}
@@ -0,0 +1,21 @@
import QtQuick
import qs.HyprChrome.Widgets.Greeter
// Offscreen render of the login panel after a rejected password.
//
// ./tools/quickshell-preview/render.sh \
// tests/LoginPanelHeadless.qml \
// .artifacts/quickshell-preview/login-panel.png 700 340
LoginContent {
width: 640
hostName: "terra"
sessionName: "hyprland"
now: new Date(2026, 8, 18, 21, 47, 38)
user: "darman"
response: "hunter2"
message: "Authentication failure"
messageIsError: true
failed: true
}
+7
View File
@@ -23,6 +23,13 @@ in
networking.hostName = "terra";
homelab.greeter = {
monitors = config.home-manager.users.darman.wayland.windowManager.hyprland.settings.monitor;
primaryOutput = "DP-2";
defaultUser = "darman";
keyboardLayout = "de";
};
services.flatpak = {
enable = true;
remotes = [{ name = "flathub"; location = "https://dl.flathub.org/repo/flathub.flatpakrepo"; }];
+2 -6
View File
@@ -2,18 +2,14 @@
# Hyprland (wayland) desktop: compositor, login manager, audio, portals.
{
imports = [ ./quickshell-greeter.nix ];
programs.hyprland.enable = true;
services.gnome.gnome-keyring.enable = true;
security.pam.services.login.enableGnomeKeyring = true;
security.pam.services.greetd.enableGnomeKeyring = true;
services.greetd = {
enable = true;
settings.default_session.command =
"${pkgs.tuigreet}/bin/tuigreet --time --cmd start-hyprland";
};
# Audio (pipewire replaces pulseaudio/jack).
security.rtkit.enable = true;
services.pipewire = {
+76
View File
@@ -0,0 +1,76 @@
{ config, lib, pkgs, ... }:
# greetd greeter: a throwaway Hyprland running quickshell's greeter.qml as the
# `greeter` user. Hyprland must exit after the login so greetd can start the
# real session, hence the exit dispatch once qs returns.
let
cfg = config.homelab.greeter;
hyprland = config.programs.hyprland.package;
toLua = lib.generators.toLua { };
shellDir = ../../dotfiles/quickshell;
session = pkgs.writeShellScript "greeter-session" ''
${lib.getExe pkgs.quickshell} -p ${shellDir}/greeter.qml
${hyprland}/bin/hyprctl dispatch 'hl.dsp.exit()'
'';
hyprConfig = pkgs.writeText "greeter-hyprland.lua" ''
${lib.concatMapStrings (m: "hl.monitor(${toLua m})\n") cfg.monitors}
hl.config(${toLua {
input = { kb_layout = cfg.keyboardLayout; numlock_by_default = true; };
animations.enabled = false;
misc = {
disable_hyprland_logo = true;
disable_splash_rendering = true;
background_color = "rgb(0a0a0a)";
};
ecosystem = { no_update_news = true; no_donation_nag = true; };
}})
hl.env("QS_GREETER_OUTPUT", ${toLua cfg.primaryOutput})
hl.env("QS_GREETER_USER", ${toLua cfg.defaultUser})
hl.env("QS_GREETER_HOST", ${toLua config.networking.hostName})
hl.env("QS_GREETER_SESSION", "start-hyprland")
hl.on("hyprland.start", function()
hl.exec_cmd("${session}")
end)
'';
in
{
options.homelab.greeter = {
monitors = lib.mkOption {
type = lib.types.listOf lib.types.attrs;
default = [ ];
description = "hl.monitor() tables; reuse the user's so outputs line up.";
};
primaryOutput = lib.mkOption {
type = lib.types.str;
default = "";
description = "Output that gets the login panel (others get the backdrop only).";
};
defaultUser = lib.mkOption {
type = lib.types.str;
default = "";
};
keyboardLayout = lib.mkOption {
type = lib.types.str;
default = "us";
};
};
config = {
services.greetd = {
enable = true;
settings.default_session.command =
"${hyprland}/bin/start-hyprland -- --config ${hyprConfig}";
};
# Hyprland and quickshell want a writable $HOME for cache/state.
users.users.greeter = {
home = "/var/lib/greeter";
createHome = true;
};
};
}