diff --git a/CLAUDE.md b/CLAUDE.md index e384ccc..bf59bbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/dotfiles/quickshell/CLAUDE.md b/dotfiles/quickshell/CLAUDE.md index f25a650..c1bb8e9 100644 --- a/dotfiles/quickshell/CLAUDE.md +++ b/dotfiles/quickshell/CLAUDE.md @@ -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. diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Greeter/Greeter.qml b/dotfiles/quickshell/HyprChrome/Widgets/Greeter/Greeter.qml new file mode 100644 index 0000000..76a388d --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Greeter/Greeter.qml @@ -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() + } + } +} diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Greeter/LoginContent.qml b/dotfiles/quickshell/HyprChrome/Widgets/Greeter/LoginContent.qml new file mode 100644 index 0000000..8d195e4 --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Greeter/LoginContent.qml @@ -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" } + } + } + } +} diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Greeter/LoginPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Greeter/LoginPanel.qml new file mode 100644 index 0000000..6a27e36 --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Greeter/LoginPanel.qml @@ -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 + } +} diff --git a/dotfiles/quickshell/greeter.qml b/dotfiles/quickshell/greeter.qml new file mode 100644 index 0000000..b30ac8f --- /dev/null +++ b/dotfiles/quickshell/greeter.qml @@ -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 {} +} diff --git a/dotfiles/quickshell/tests/LoginPanelHeadless.qml b/dotfiles/quickshell/tests/LoginPanelHeadless.qml new file mode 100644 index 0000000..4a0026a --- /dev/null +++ b/dotfiles/quickshell/tests/LoginPanelHeadless.qml @@ -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 +} diff --git a/hosts/terra/configuration.nix b/hosts/terra/configuration.nix index bcf40d3..e57dd0a 100644 --- a/hosts/terra/configuration.nix +++ b/hosts/terra/configuration.nix @@ -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"; }]; diff --git a/services/desktop/desktop-hyprland.nix b/services/desktop/desktop-hyprland.nix index 7ba7725..9100f16 100644 --- a/services/desktop/desktop-hyprland.nix +++ b/services/desktop/desktop-hyprland.nix @@ -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 = { diff --git a/services/desktop/quickshell-greeter.nix b/services/desktop/quickshell-greeter.nix new file mode 100644 index 0000000..8402c86 --- /dev/null +++ b/services/desktop/quickshell-greeter.nix @@ -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; + }; + }; +}