From 1dd16d3e9976dd4cac877c941257be0f83e2b4e5 Mon Sep 17 00:00:00 2001 From: Moze Date: Thu, 30 Apr 2026 01:45:37 +0200 Subject: [PATCH] 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> --- README.md | 65 +++++++ greetd/config.toml.example | 6 + greetd/niri-greeter.kdl | 3 + greetd/start-greeter.sh | 4 + install.sh | 39 ++++ shell.qml | 383 +++++++++++++++++++++++++++++++++++++ sync/noctalia-greeter-sync | 49 +++++ sync/run | 3 + test/check-syntax.sh | 197 +++++++++++++++++++ 9 files changed, 749 insertions(+) create mode 100644 README.md create mode 100644 greetd/config.toml.example create mode 100644 greetd/niri-greeter.kdl create mode 100755 greetd/start-greeter.sh create mode 100755 install.sh create mode 100644 shell.qml create mode 100755 sync/noctalia-greeter-sync create mode 100755 sync/run create mode 100755 test/check-syntax.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..56f74b5 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# noctalia-greeter + +A QuickShell QML login greeter for [greetd](https://git.sr.ht/~kennylevinsen/greetd), styled to match the [noctalia-shell](https://github.com/nocturnalastro/noctalia-shell) lockscreen. + +## Architecture + +``` +greetd (VT7, _greeter user) + └── dbus-run-session niri --config /etc/greetd/niri-greeter.kdl + └── /etc/greetd/start-greeter.sh + └── qs -c ~/.config/noctalia-greeter/ (shell.qml) + reads /var/lib/noctalia-greeter-theme/{colors.json,wallpaper.jpg} + +noctalia-greeter-sync (runit, moze user) + inotifywait ~/.config/noctalia/ + ~/.cache/noctalia/ + → copies colors.json + wallpaper on any noctalia style change + → writes to /var/lib/noctalia-greeter-theme/ (world-readable) +``` + +The `_greeter` user never touches `/home/moze`. Theme data is staged to a world-readable directory by the sync service running as moze. + +## Files + +| Source | Deployed to | +|--------|-------------| +| `shell.qml` | `~/.config/noctalia-greeter/shell.qml` | +| `sync/noctalia-greeter-sync` | `/usr/local/bin/noctalia-greeter-sync` | +| `sync/run` | `/etc/sv/noctalia-greeter-sync/run` | +| `greetd/niri-greeter.kdl` | `/etc/greetd/niri-greeter.kdl` | +| `greetd/start-greeter.sh` | `/etc/greetd/start-greeter.sh` | +| `greetd/config.toml.example` | `/etc/greetd/config.toml` (edit before deploying) | + +## Install + +```sh +sudo ./install.sh +``` + +Requires: `greetd`, `niri`, `quickshell` (`qs`), `dbus`, `inotify-tools`. + +## Rollback + +Edit `/etc/greetd/config.toml`: +```toml +[terminal] +vt = 7 + +[default_session] +command = "sway --config /etc/greetd/sway-greeter.conf" +user = "_greeter" +``` + +## Theme sync + +Change colors/wallpaper in noctalia shell → greeter auto-updates next login (no restart needed). + +The sync service watches: +- `~/.config/noctalia/colors.json` +- `~/.cache/noctalia/wallpapers.json` + +## Tests + +```sh +./test/check-syntax.sh +``` diff --git a/greetd/config.toml.example b/greetd/config.toml.example new file mode 100644 index 0000000..e828353 --- /dev/null +++ b/greetd/config.toml.example @@ -0,0 +1,6 @@ +[terminal] +vt = 7 + +[default_session] +command = "/usr/sbin/dbus-run-session /usr/sbin/niri --config /etc/greetd/niri-greeter.kdl" +user = "_greeter" diff --git a/greetd/niri-greeter.kdl b/greetd/niri-greeter.kdl new file mode 100644 index 0000000..b992766 --- /dev/null +++ b/greetd/niri-greeter.kdl @@ -0,0 +1,3 @@ +// Minimal niri config for the greetd greeter session. +// Spawns the QuickShell greeter; quits niri when qs exits. +spawn-at-startup "sh" "-c" "/etc/greetd/start-greeter.sh" diff --git a/greetd/start-greeter.sh b/greetd/start-greeter.sh new file mode 100755 index 0000000..19da1e3 --- /dev/null +++ b/greetd/start-greeter.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Launch QuickShell greeter; quit niri when it exits (NIRI_SOCKET inherited from niri env). +/usr/sbin/qs -c /home/moze/.config/noctalia-greeter +/usr/sbin/niri msg action quit --skip-confirmation 2>/dev/null || true diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..cbc4597 --- /dev/null +++ b/install.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# install.sh — deploy noctalia-greeter to system paths +set -e + +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root: sudo ./install.sh" >&2 + exit 1 +fi + +echo "==> Installing noctalia-greeter..." + +# QuickShell config (read by qs as _greeter) +install -Dm644 "$REPO_DIR/shell.qml" /home/moze/.config/noctalia-greeter/shell.qml +chown moze:moze /home/moze/.config/noctalia-greeter/shell.qml + +# Sync daemon +install -Dm755 "$REPO_DIR/sync/noctalia-greeter-sync" /usr/local/bin/noctalia-greeter-sync + +# Runit service +install -Dm755 "$REPO_DIR/sync/run" /etc/sv/noctalia-greeter-sync/run + +# Greeter compositor config +install -Dm644 "$REPO_DIR/greetd/niri-greeter.kdl" /etc/greetd/niri-greeter.kdl +install -Dm755 "$REPO_DIR/greetd/start-greeter.sh" /etc/greetd/start-greeter.sh + +# Theme staging dir +install -d -m755 /var/lib/noctalia-greeter-theme +chown moze:moze /var/lib/noctalia-greeter-theme + +echo "" +echo "==> Next steps:" +echo " 1. Edit /etc/greetd/config.toml (see greetd/config.toml.example)" +echo " 2. Enable sync service: ln -s /etc/sv/noctalia-greeter-sync /var/service/" +echo " 3. Run initial sync: su -s /bin/sh moze -c /usr/local/bin/noctalia-greeter-sync &" +echo " 4. Restart greetd: sv restart greetd" +echo "" +echo "Done." diff --git a/shell.qml b/shell.qml new file mode 100644 index 0000000..a2517b0 --- /dev/null +++ b/shell.qml @@ -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 + } + } + } +} diff --git a/sync/noctalia-greeter-sync b/sync/noctalia-greeter-sync new file mode 100755 index 0000000..0cd280b --- /dev/null +++ b/sync/noctalia-greeter-sync @@ -0,0 +1,49 @@ +#!/bin/sh +# Syncs noctalia theme to /var/lib/noctalia-greeter-theme/ for the _greeter user. +export HOME=/home/moze +export USER=moze + +THEME_DIR="/var/lib/noctalia-greeter-theme" +COLORS="$HOME/.config/noctalia/colors.json" +WALLPAPERS_JSON="$HOME/.cache/noctalia/wallpapers.json" + +do_sync() { + if [ -f "$COLORS" ]; then + cp -f "$COLORS" "$THEME_DIR/colors.json" 2>/dev/null + chmod 644 "$THEME_DIR/colors.json" 2>/dev/null + fi + + if [ -f "$WALLPAPERS_JSON" ]; then + WP=$(python3 -c " +import json, sys +try: + d = json.load(open('$WALLPAPERS_JSON')) + m = d.get('wallpapers', {}) + for s in ['eDP-1', 'DP-1'] + list(m.keys()): + if s in m: + p = m[s].get('dark') or m[s].get('light') or '' + if p: + print(p) + sys.exit(0) + fb = d.get('defaultWallpaper', '') + if fb: print(fb) +except Exception as e: + sys.stderr.write(str(e) + chr(10)) +" 2>/dev/null) + if [ -n "$WP" ] && [ -f "$WP" ]; then + cp -f "$WP" "$THEME_DIR/wallpaper.jpg" 2>/dev/null + chmod 644 "$THEME_DIR/wallpaper.jpg" 2>/dev/null + fi + fi +} + +do_sync + +exec inotifywait -m -q \ + -e close_write,moved_to,create \ + "$HOME/.config/noctalia/" \ + "$HOME/.cache/noctalia/" 2>/dev/null | +while IFS= read -r _line; do + sleep 0.3 + do_sync +done diff --git a/sync/run b/sync/run new file mode 100755 index 0000000..9996b32 --- /dev/null +++ b/sync/run @@ -0,0 +1,3 @@ +#!/bin/sh +exec 2>&1 +exec chpst -u moze env HOME=/home/moze USER=moze /usr/local/bin/noctalia-greeter-sync diff --git a/test/check-syntax.sh b/test/check-syntax.sh new file mode 100755 index 0000000..510f488 --- /dev/null +++ b/test/check-syntax.sh @@ -0,0 +1,197 @@ +#!/bin/sh +# test/check-syntax.sh — static syntax checks for noctalia-greeter +# No runtime required. Run from repo root or any location. +set -e + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PASS=0 +FAIL=0 + +ok() { echo " [PASS] $1"; PASS=$((PASS+1)); } +fail() { echo " [FAIL] $1"; echo " $2"; FAIL=$((FAIL+1)); } + +echo "=== noctalia-greeter syntax checks ===" +echo "" + +# ── Shell scripts ────────────────────────────────────────────────────────────── +echo "-- Shell syntax (bash -n) --" + +for f in \ + "sync/noctalia-greeter-sync" \ + "sync/run" \ + "greetd/start-greeter.sh" \ + "install.sh" +do + path="$REPO_DIR/$f" + if [ ! -f "$path" ]; then + fail "$f" "file not found: $path" + elif out=$(bash -n "$path" 2>&1); then + ok "$f" + else + fail "$f" "$out" + fi +done + +# ── Embedded Python snippet ──────────────────────────────────────────────────── +echo "" +echo "-- Python snippet in noctalia-greeter-sync --" + +SYNC="$REPO_DIR/sync/noctalia-greeter-sync" +if [ -f "$SYNC" ]; then + # Extract python3 heredoc: lines between 'python3 -c "' and closing '"' + PY_SNIPPET=$(sed -n '/python3 -c "/,/^" /{ /python3 -c "/d; /^" /d; p }' "$SYNC") + if [ -n "$PY_SNIPPET" ]; then + if out=$(echo "$PY_SNIPPET" | python3 -c " +import ast, sys +src = sys.stdin.read() +try: + ast.parse(src) + print('ok') +except SyntaxError as e: + print('SyntaxError: ' + str(e)) + sys.exit(1) +" 2>&1); then + ok "embedded python snippet (ast.parse)" + else + fail "embedded python snippet" "$out" + fi + else + fail "embedded python snippet" "could not extract snippet from $SYNC" + fi +else + fail "embedded python snippet" "sync script not found" +fi + +# ── Niri config ──────────────────────────────────────────────────────────────── +echo "" +echo "-- Niri config (niri validate) --" + +KDL="$REPO_DIR/greetd/niri-greeter.kdl" +if [ ! -f "$KDL" ]; then + fail "niri-greeter.kdl" "file not found" +elif ! command -v niri >/dev/null 2>&1; then + echo " [SKIP] niri-greeter.kdl — niri not in PATH" +elif out=$(niri validate -c "$KDL" 2>&1); then + ok "niri-greeter.kdl" +else + fail "niri-greeter.kdl" "$out" +fi + +# ── QML structural check ─────────────────────────────────────────────────────── +echo "" +echo "-- QML structural check --" + +QML="$REPO_DIR/shell.qml" +if [ ! -f "$QML" ]; then + fail "shell.qml" "file not found" +elif command -v qmllint >/dev/null 2>&1; then + if out=$(qmllint "$QML" 2>&1); then + ok "shell.qml (qmllint)" + else + fail "shell.qml (qmllint)" "$out" + fi +else + # Fallback: brace/bracket balance via Python + if out=$(python3 - "$QML" << 'EOF' +import sys + +path = sys.argv[1] +src = open(path).read() +depth = 0 +in_str = False +str_char = None +i = 0 +errors = [] + +while i < len(src): + c = src[i] + if in_str: + if c == '\\': + i += 2 + continue + if c == str_char: + in_str = False + else: + if c in ('"', "'"): + in_str = True + str_char = c + elif c == '{': + depth += 1 + elif c == '}': + depth -= 1 + if depth < 0: + line = src[:i].count('\n') + 1 + errors.append(f"line {line}: unexpected '}}' (depth went negative)") + depth = 0 + i += 1 + +if depth != 0: + errors.append(f"unbalanced braces: depth={depth} at EOF") + +# Check imports present +required = ["import Quickshell", "Quickshell.Services.Greetd", "ShellRoot"] +for r in required: + if r not in src: + errors.append(f"missing expected token: '{r}'") + +if errors: + for e in errors: + print(e) + sys.exit(1) +else: + print("ok") +EOF + 2>&1); then + ok "shell.qml (brace balance + required imports)" + else + fail "shell.qml" "$out" + fi +fi + +# ── Required files present ───────────────────────────────────────────────────── +echo "" +echo "-- Required files present --" + +for f in \ + "shell.qml" \ + "sync/noctalia-greeter-sync" \ + "sync/run" \ + "greetd/niri-greeter.kdl" \ + "greetd/start-greeter.sh" \ + "greetd/config.toml.example" \ + "install.sh" \ + "README.md" +do + path="$REPO_DIR/$f" + if [ -f "$path" ]; then + ok "$f exists" + else + fail "$f" "missing: $path" + fi +done + +# ── Permissions check ────────────────────────────────────────────────────────── +echo "" +echo "-- Executable bits --" + +for f in \ + "sync/noctalia-greeter-sync" \ + "sync/run" \ + "greetd/start-greeter.sh" \ + "install.sh" +do + path="$REPO_DIR/$f" + if [ ! -f "$path" ]; then + fail "$f" "not found" + elif [ -x "$path" ]; then + ok "$f is executable" + else + fail "$f" "not executable (chmod +x $path)" + fi +done + +# ── Summary ──────────────────────────────────────────────────────────────────── +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" + +[ "$FAIL" -eq 0 ]