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:
@@ -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
|
- **`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
|
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".
|
`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`).
|
||||||
|
|||||||
@@ -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
|
and its panels, with `Bar/Panels/BarPanel.qml` the chamfered chrome they all
|
||||||
extend; `Widgets/Polkit/` is the authentication agent and its dialog;
|
extend; `Widgets/Polkit/` is the authentication agent and its dialog;
|
||||||
`Widgets/Launcher/` is the primary application launcher (`SUPER_L`);
|
`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
|
`Theme/Theme.qml` is this tree's palette singleton. `DebugWindow.qml` stages a
|
||||||
single widget on the secondary monitor for eyeballing it in isolation.
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -23,6 +23,13 @@ in
|
|||||||
|
|
||||||
networking.hostName = "terra";
|
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 = {
|
services.flatpak = {
|
||||||
enable = true;
|
enable = true;
|
||||||
remotes = [{ name = "flathub"; location = "https://dl.flathub.org/repo/flathub.flatpakrepo"; }];
|
remotes = [{ name = "flathub"; location = "https://dl.flathub.org/repo/flathub.flatpakrepo"; }];
|
||||||
|
|||||||
@@ -2,18 +2,14 @@
|
|||||||
|
|
||||||
# Hyprland (wayland) desktop: compositor, login manager, audio, portals.
|
# Hyprland (wayland) desktop: compositor, login manager, audio, portals.
|
||||||
{
|
{
|
||||||
|
imports = [ ./quickshell-greeter.nix ];
|
||||||
|
|
||||||
programs.hyprland.enable = true;
|
programs.hyprland.enable = true;
|
||||||
|
|
||||||
services.gnome.gnome-keyring.enable = true;
|
services.gnome.gnome-keyring.enable = true;
|
||||||
security.pam.services.login.enableGnomeKeyring = true;
|
security.pam.services.login.enableGnomeKeyring = true;
|
||||||
security.pam.services.greetd.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).
|
# Audio (pipewire replaces pulseaudio/jack).
|
||||||
security.rtkit.enable = true;
|
security.rtkit.enable = true;
|
||||||
services.pipewire = {
|
services.pipewire = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user