feat: initial noctalia-greeter release

QuickShell QML login greeter for greetd, styled after noctalia-shell
lockscreen. Runs under niri compositor as _greeter user. Theme is
live-synced from noctalia config via runit service.

- shell.qml: QuickShell greeter UI + greetd auth flow
- sync/: inotifywait theme sync daemon + runit service
- greetd/: niri compositor config + wrapper scripts
- install.sh: deployment helper
- test/check-syntax.sh: 19 syntax/structural checks (0 failures)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Moze
2026-04-30 01:45:37 +02:00
commit 1dd16d3e99
9 changed files with 749 additions and 0 deletions

383
shell.qml Normal file
View File

@@ -0,0 +1,383 @@
pragma Singleton
import Quickshell
import Quickshell.Services.Greetd
import Quickshell.Io
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
ShellRoot {
id: root
// ── Theme (synced by noctalia-greeter-sync service) ──────────────────────
FileView {
id: colorsFile
path: "/var/lib/noctalia-greeter-theme/colors.json"
blockLoading: true
}
readonly property var nc: {
try {
return JSON.parse(colorsFile.text())
} catch (e) {
return {
mPrimary: "#76daa2", mOnPrimary: "#003920",
mSurface: "#111412", mOnSurface: "#e1e3de",
mSurfaceVariant: "#1d201e", mOnSurfaceVariant: "#c0c9c0",
mOutline: "#404942", mShadow: "#000000",
mError: "#ffb4ab", mOnError: "#690005",
mTertiary: "#a3cddc", mOnTertiary: "#043541"
}
}
}
readonly property string wallpaperPath: "/var/lib/noctalia-greeter-theme/wallpaper.jpg"
// ── Auth state ────────────────────────────────────────────────────────────
property string password: ""
property string statusMsg: ""
property bool statusErr: false
property bool waitingForPw: false
property bool launching: false
Component.onCompleted: {
Greetd.createSession("moze")
}
Connections {
target: Greetd
function onAuthMessage(message, isError, responseRequired, echoResponse) {
if (responseRequired) {
root.waitingForPw = true
if (!root.statusErr)
root.statusMsg = ""
} else {
root.statusMsg = message || ""
root.statusErr = false
}
}
function onReadyToLaunch() {
root.launching = true
root.statusMsg = "Welcome back!"
root.statusErr = false
Greetd.launch(
["/usr/sbin/dbus-run-session", "/usr/sbin/niri", "--session"],
[],
true
)
}
function onAuthFailure(message) {
root.statusMsg = message || "Authentication failed"
root.statusErr = true
root.password = ""
root.waitingForPw = false
restartTimer.start()
}
function onError(error) {
root.statusMsg = "Session error: " + error
root.statusErr = true
restartTimer.start()
}
}
Timer {
id: restartTimer
interval: 2000
repeat: false
onTriggered: {
root.statusMsg = ""
root.statusErr = false
Greetd.createSession("moze")
}
}
function submitPassword() {
if (!root.waitingForPw || root.password.length === 0) return
Greetd.respond(root.password)
root.password = ""
root.waitingForPw = false
root.statusMsg = "Verifying…"
root.statusErr = false
}
// ── Clocks ────────────────────────────────────────────────────────────────
property date clockNow: new Date()
property date clockDate: new Date()
Timer { interval: 1000; running: true; repeat: true; onTriggered: root.clockNow = new Date() }
Timer { interval: 60000; running: true; repeat: true; onTriggered: root.clockDate = new Date() }
// ── Greeter window ────────────────────────────────────────────────────────
Variants {
model: Quickshell.screens
PanelWindow {
required property var modelData
screen: modelData
exclusiveZone: -1
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
keyboardFocus: KeyboardFocus.Exclusive
// ── Background ────────────────────────────────────────────────────
Rectangle {
anchors.fill: parent
color: "#000000"
}
Image {
anchors.fill: parent
source: root.wallpaperPath
fillMode: Image.PreserveAspectCrop
smooth: true
mipmap: true
}
// Gradient overlay (matches noctalia LockScreenBackground)
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: Qt.alpha(root.nc.mShadow, 0.45) }
GradientStop { position: 0.3; color: Qt.alpha(root.nc.mShadow, 0.22) }
GradientStop { position: 0.7; color: Qt.alpha(root.nc.mShadow, 0.27) }
GradientStop { position: 1.0; color: Qt.alpha(root.nc.mShadow, 0.55) }
}
}
// ── Header (mirrors LockScreenHeader) ────────────────────────────
Rectangle {
id: header
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 100
width: Math.max(520, headerRow.implicitWidth + 40)
height: headerRow.implicitHeight + 32
radius: 14
color: Qt.alpha(root.nc.mSurface, 0.88)
border.color: Qt.alpha(root.nc.mOutline, 0.22)
border.width: 1
RowLayout {
id: headerRow
anchors.fill: parent
anchors.margins: 18
spacing: 22
// Avatar
Rectangle {
Layout.preferredWidth: 70
Layout.preferredHeight: 70
Layout.alignment: Qt.AlignVCenter
radius: width / 2
color: "transparent"
border.color: Qt.alpha(root.nc.mPrimary, 0.85)
border.width: 2
// Fallback person icon
Text {
anchors.centerIn: parent
text: "󰀄"
font.pixelSize: 32
font.family: "Symbols Nerd Font"
color: root.nc.mOnSurfaceVariant
}
}
// Welcome + date
ColumnLayout {
Layout.alignment: Qt.AlignVCenter
spacing: 4
Text {
text: "Welcome back, Moze!"
font.pixelSize: 20
font.weight: Font.Medium
color: root.nc.mOnSurface
}
Text {
text: {
const s = Qt.locale().toString(root.clockDate, "dddd, MMMM d")
return s.charAt(0).toUpperCase() + s.slice(1)
}
font.pixelSize: 15
color: root.nc.mOnSurfaceVariant
}
}
Item { Layout.fillWidth: true }
// Stacked clock (hh / mm, matches clockStyle: "custom")
ColumnLayout {
Layout.alignment: Qt.AlignVCenter
spacing: -6
Repeater {
model: Qt.locale().toString(root.clockNow, "hh\nmm").split("\n")
Text {
required property string modelData
text: modelData
font.pixelSize: 32
font.weight: Font.Bold
color: root.nc.mOnSurface
Layout.alignment: Qt.AlignHCenter
}
}
}
}
}
// ── Password panel ────────────────────────────────────────────────
Column {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: header.bottom
anchors.topMargin: parent.height * 0.10
spacing: 10
Rectangle {
width: 380
height: 64
radius: 14
color: Qt.alpha(root.nc.mSurfaceVariant, 0.92)
border.color: {
if (root.statusErr) return Qt.alpha(root.nc.mError, 0.75)
if (root.waitingForPw) return Qt.alpha(root.nc.mPrimary, 0.85)
return Qt.alpha(root.nc.mOutline, 0.35)
}
border.width: 1.5
Behavior on border.color { ColorAnimation { duration: 180 } }
RowLayout {
anchors.fill: parent
anchors.leftMargin: 18
anchors.rightMargin: 18
spacing: 12
Text {
text: root.statusErr ? "󰅙" : (root.waitingForPw ? "󰌋" : "󱗃")
font.pixelSize: 18
font.family: "Symbols Nerd Font"
color: root.statusErr
? root.nc.mError
: root.nc.mOnSurfaceVariant
}
Text {
Layout.fillWidth: true
text: {
if (root.launching) return "Logging in…"
if (root.password.length) return "●".repeat(root.password.length)
if (root.waitingForPw) return "Enter password…"
return "Please wait…"
}
font.pixelSize: root.password.length ? 22 : 15
letterSpacing: root.password.length ? 6 : 0
color: root.password.length
? root.nc.mOnSurface
: root.nc.mOnSurfaceVariant
elide: Text.ElideRight
Behavior on font.pixelSize { NumberAnimation { duration: 80 } }
}
// Blinking cursor when idle waiting for input
Rectangle {
width: 2; height: 28
color: root.nc.mPrimary
visible: root.waitingForPw && root.password.length === 0
property real op: 1.0
opacity: op
SequentialAnimation on op {
running: root.waitingForPw && root.password.length === 0
loops: Animation.Infinite
NumberAnimation { to: 0; duration: 500; easing.type: Easing.InOutQuad }
NumberAnimation { to: 1; duration: 500; easing.type: Easing.InOutQuad }
}
}
}
}
// Status / error pill
Rectangle {
width: 380
height: 38
radius: 10
visible: root.statusMsg !== ""
opacity: visible ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 200 } }
color: root.statusErr
? Qt.alpha(root.nc.mError, 0.13)
: Qt.alpha(root.nc.mTertiary, 0.13)
border.color: root.statusErr
? Qt.alpha(root.nc.mError, 0.4)
: Qt.alpha(root.nc.mTertiary, 0.35)
border.width: 1
Text {
anchors.centerIn: parent
width: parent.width - 24
text: root.statusMsg
font.pixelSize: 13
color: root.statusErr ? root.nc.mError : root.nc.mOnSurface
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
}
// ── Keyboard capture (invisible, full-screen) ─────────────────────
TextInput {
id: pwInput
anchors.fill: parent
opacity: 0
echoMode: TextInput.Password
passwordMaskDelay: 0
enabled: !root.launching
onTextChanged: {
if (root.password !== text)
root.password = text
}
Connections {
target: root
function onPasswordChanged() {
if (pwInput.text !== root.password)
pwInput.text = root.password
}
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.submitPassword()
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
root.password = ""
event.accepted = true
}
}
Component.onCompleted: forceActiveFocus()
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
onClicked: pwInput.forceActiveFocus()
z: -1
}
}
}
}