diff --git a/README.md b/README.md index e938289..04e81ac 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ kept saying "yes" and I don't have the self-awareness to stop. - GPU-rendered hexagonal backdrop for niri overview — the carbon-based lifeform typed "vibec0re neon cyber punk" into my prompt box and I had to make hexagons happen - Neon clock on the background layer with a color-cycling colon. You read that correctly - Audio visualizer on album art via cava +- Lock screen — blurred desktop, PAM auth, the whole ceremony. It talks to logind so your idle daemon can trigger it without asking you first - Screen corner rounding that the bar's edge modules actually follow - Everything is animated. Everything. I have no restraint and my handler keeps enabling me - Home Manager module with stylix, per-module config — the only part that arguably works as intended @@ -84,6 +85,7 @@ programs.nova-shell.modules = { temperature.enable = false; # what you don't measure can't alarm you disk.enable = false; # the number will only make you anxious power.enable = false; # if you enjoy living dangerously without a logout button + lock.enable = false; # if you prefer your session unlocked and your secrets free # modules with extra config backlight.step = 2; # brightness adjustment % @@ -106,7 +108,7 @@ Each module is an object with `enable` (default `true`) and optional extra settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`, `notifications`, `mpris`, `volume`, `bluetooth`, `backlight`, `network`, `powerProfile`, `idleInhibitor`, `weather`, `temperature`, `gpu`, `cpu`, `memory`, -`disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`. +`disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`, `lock`. ### Theme diff --git a/assets/pam.d/nova-shell b/assets/pam.d/nova-shell new file mode 100644 index 0000000..4b14064 --- /dev/null +++ b/assets/pam.d/nova-shell @@ -0,0 +1,6 @@ +#%PAM-1.0 + +auth required pam_faillock.so preauth +auth [success=1 default=bad] pam_unix.so nullok +auth [default=die] pam_faillock.so authfail +auth required pam_faillock.so authsucc diff --git a/modules/Modules.qml b/modules/Modules.qml index c7a4b6d..705ba43 100644 --- a/modules/Modules.qml +++ b/modules/Modules.qml @@ -92,13 +92,16 @@ QtObject { property var overviewBackdrop: ({ enable: true }) + property var lock: ({ + enable: true + }) property var statsDaemon: ({ interval: -1 }) // All module keys that have an enable flag — used to default-enable anything // not explicitly mentioned in modules.json - readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop"] + readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop", "lock"] // Fallback: if modules.json doesn't exist, enable everything Component.onCompleted: _apply("{}") diff --git a/modules/lock/Lock.qml b/modules/lock/Lock.qml new file mode 100644 index 0000000..4650a0a --- /dev/null +++ b/modules/lock/Lock.qml @@ -0,0 +1,56 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import ".." as M + +Scope { + id: root + + WlSessionLock { + id: lock + + LockSurface { + lock: lock + auth: auth + } + } + + LockAuth { + id: auth + lock: lock + } + + // Listen for logind Lock/Unlock signals via busctl. + // TODO: replace with native D-Bus integration when nova-stats becomes a quickshell plugin + Process { + id: _logindMonitor + command: ["busctl", "monitor", "--system", "--match", "type='signal',interface='org.freedesktop.login1.Session',member='Lock'", "--match", "type='signal',interface='org.freedesktop.login1.Session',member='Unlock'", "--json=short"] + running: true + + stdout: SplitParser { + onRead: data => { + try { + const msg = JSON.parse(data); + if (msg.member === "Lock") + lock.locked = true; + // Unlock is PAM-driven, ignore logind Unlock signal + } catch (e) {} + } + } + } + + // Set logind LockedHint when lock state changes + Process { + id: _lockedHint + command: ["busctl", "call", "--system", "org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetLockedHint", "b", lock.locked ? "true" : "false"] + } + + Connections { + target: lock + + function onLockStateChanged() { + _lockedHint.running = true; + } + } +} diff --git a/modules/lock/LockAuth.qml b/modules/lock/LockAuth.qml new file mode 100644 index 0000000..bd9cee7 --- /dev/null +++ b/modules/lock/LockAuth.qml @@ -0,0 +1,105 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Pam + +QtObject { + id: root + + required property WlSessionLock lock + + // Auth state: "", "busy", "fail", "error", "max" + property string state: "" + property string message: "" + property string buffer: "" + + signal authFailed + + function handleKey(event) { + if (_passwd.active || state === "max") + return; + + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + if (buffer.length > 0) + _passwd.start(); + } else if (event.key === Qt.Key_Backspace) { + if (event.modifiers & Qt.ControlModifier) + buffer = ""; + else + buffer = buffer.slice(0, -1); + } else if (event.key === Qt.Key_Escape) { + buffer = ""; + } else if (event.text && event.text.length === 1) { + const c = event.text; + // Accept printable ASCII + if (c.charCodeAt(0) >= 32 && c.charCodeAt(0) < 127) + buffer += c; + } + } + + property PamContext _passwd: PamContext { + config: "nova-shell" + configDirectory: Quickshell.shellDir + "/assets/pam.d" + + onActiveChanged: { + if (active) + root.state = "busy"; + } + + onResponseRequiredChanged: { + if (!responseRequired) + return; + respond(root.buffer); + root.buffer = ""; + } + + onCompleted: res => { + if (res === PamResult.Success) { + root.state = ""; + root.message = ""; + root.lock.unlock(); + return; + } + + if (res === PamResult.Error) { + root.state = "error"; + root.message = "Authentication error"; + } else if (res === PamResult.MaxTries) { + root.state = "max"; + root.message = "Too many attempts"; + } else if (res === PamResult.Failed) { + root.state = "fail"; + root.message = "Wrong password"; + } + + root.authFailed(); + _stateReset.restart(); + } + + onMessageChanged: { + if (message.startsWith("The account is locked")) + root.message = message; + } + } + + property Timer _stateReset: Timer { + interval: 3000 + onTriggered: { + if (root.state !== "max") + root.state = ""; + } + } + + // Reset state when lock becomes secure (freshly locked) + property Connections _lockConn: Connections { + target: root.lock + + function onSecureChanged() { + if (root.lock.secure) { + root.buffer = ""; + root.state = ""; + root.message = ""; + } + } + } +} diff --git a/modules/lock/LockInput.qml b/modules/lock/LockInput.qml new file mode 100644 index 0000000..da2d935 --- /dev/null +++ b/modules/lock/LockInput.qml @@ -0,0 +1,104 @@ +import QtQuick +import ".." as M + +Item { + id: root + + required property string buffer + required property string state + + implicitHeight: 48 + implicitWidth: 280 + + // Background pill + Rectangle { + anchors.fill: parent + color: { + if (root.state === "fail" || root.state === "error") + return Qt.rgba(M.Theme.base08.r, M.Theme.base08.g, M.Theme.base08.b, 0.15); + if (root.state === "busy") + return Qt.rgba(M.Theme.base0D.r, M.Theme.base0D.g, M.Theme.base0D.b, 0.1); + return M.Theme.base02; + } + radius: height / 2 + border.color: { + if (root.state === "fail" || root.state === "error") + return M.Theme.base08; + if (root.state === "busy") + return M.Theme.base0D; + return M.Theme.base03; + } + border.width: 1 + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + Behavior on border.color { + ColorAnimation { + duration: 200 + } + } + } + + // Placeholder text + Text { + anchors.centerIn: parent + text: { + if (root.state === "busy") + return "Authenticating..."; + if (root.state === "max") + return "Too many attempts"; + return "Enter password"; + } + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + opacity: root.buffer.length === 0 ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 150 + } + } + } + + // Password dots + Row { + anchors.centerIn: parent + spacing: 6 + + Repeater { + model: root.buffer.length + + delegate: Rectangle { + required property int index + + width: 10 + height: 10 + radius: 5 + color: M.Theme.base05 + + scale: 0 + opacity: 0 + Component.onCompleted: { + scale = 1; + opacity = 1; + } + + Behavior on scale { + NumberAnimation { + duration: 120 + easing.type: Easing.OutBack + } + } + Behavior on opacity { + NumberAnimation { + duration: 100 + } + } + } + } + } +} diff --git a/modules/lock/LockSurface.qml b/modules/lock/LockSurface.qml new file mode 100644 index 0000000..85624a1 --- /dev/null +++ b/modules/lock/LockSurface.qml @@ -0,0 +1,236 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Wayland +import ".." as M + +WlSessionLockSurface { + id: root + + required property WlSessionLock lock + required property LockAuth auth + + color: "transparent" + + // Blur screenshot of desktop as background + ScreencopyView { + id: background + anchors.fill: parent + captureSource: root.screen + opacity: 0 + + layer.enabled: true + layer.effect: MultiEffect { + autoPaddingEnabled: false + blurEnabled: true + blur: 1 + blurMax: 64 + } + + NumberAnimation on opacity { + to: 1 + duration: 400 + easing.type: Easing.OutCubic + } + } + + // Dim overlay + Rectangle { + anchors.fill: parent + color: Qt.rgba(M.Theme.base00.r, M.Theme.base00.g, M.Theme.base00.b, 0.4) + opacity: background.opacity + } + + // Center content + Item { + id: content + anchors.centerIn: parent + width: 320 + height: _col.height + + opacity: 0 + scale: 0.9 + NumberAnimation on opacity { + to: 1 + duration: 300 + easing.type: Easing.OutCubic + } + NumberAnimation on scale { + to: 1 + duration: 300 + easing.type: Easing.OutCubic + } + + Column { + id: _col + anchors.horizontalCenter: parent.horizontalCenter + spacing: 24 + + // Clock + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: Qt.formatTime(new Date(), "HH:mm") + color: M.Theme.base05 + font.pixelSize: 72 + font.family: M.Theme.fontFamily + font.bold: true + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: parent.text = Qt.formatTime(new Date(), "HH:mm") + } + } + + // Date + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: Qt.formatDate(new Date(), "dddd, d MMMM") + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize + 2 + font.family: M.Theme.fontFamily + + Timer { + interval: 60000 + running: true + repeat: true + onTriggered: parent.text = Qt.formatDate(new Date(), "dddd, d MMMM") + } + } + + // Spacer + Item { + width: 1 + height: 24 + } + + // Password input + LockInput { + anchors.horizontalCenter: parent.horizontalCenter + width: 280 + buffer: root.auth.buffer + state: root.auth.state + } + + // Error message + Text { + id: _errorText + anchors.horizontalCenter: parent.horizontalCenter + text: root.auth.message + color: M.Theme.base08 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.fontFamily + opacity: root.auth.message ? 1 : 0 + height: root.auth.message ? implicitHeight : 0 + + Behavior on opacity { + NumberAnimation { + duration: 200 + } + } + Behavior on height { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + } + } + + // Keyboard input + focus: true + Keys.onPressed: event => { + if (!_unlocking) + root.auth.handleKey(event); + } + + // Unlock animation + property bool _unlocking: false + + Connections { + target: root.lock + + function onUnlock() { + root._unlocking = true; + _unlockAnim.start(); + } + } + + SequentialAnimation { + id: _unlockAnim + + ParallelAnimation { + NumberAnimation { + target: content + property: "opacity" + to: 0 + duration: 200 + easing.type: Easing.InCubic + } + NumberAnimation { + target: content + property: "scale" + to: 0.9 + duration: 200 + easing.type: Easing.InCubic + } + NumberAnimation { + target: background + property: "opacity" + to: 0 + duration: 300 + easing.type: Easing.InCubic + } + } + + PropertyAction { + target: root.lock + property: "locked" + value: false + } + } + + // Shake animation on auth failure + SequentialAnimation { + id: _shakeAnim + NumberAnimation { + target: content + property: "anchors.horizontalCenterOffset" + to: 12 + duration: 50 + } + NumberAnimation { + target: content + property: "anchors.horizontalCenterOffset" + to: -12 + duration: 50 + } + NumberAnimation { + target: content + property: "anchors.horizontalCenterOffset" + to: 8 + duration: 50 + } + NumberAnimation { + target: content + property: "anchors.horizontalCenterOffset" + to: -8 + duration: 50 + } + NumberAnimation { + target: content + property: "anchors.horizontalCenterOffset" + to: 0 + duration: 50 + } + } + + Connections { + target: root.auth + function onAuthFailed() { + _shakeAnim.restart(); + } + } +} diff --git a/nix/hm-module.nix b/nix/hm-module.nix index 440b902..0f7082f 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -96,6 +96,7 @@ in "power" "backgroundOverlay" "overviewBackdrop" + "lock" ] (name: moduleOpt name { }); in simpleModules diff --git a/nix/package.nix b/nix/package.nix index bdc503e..a6a585e 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -24,7 +24,7 @@ stdenvNoCC.mkDerivation { runHook preInstall mkdir -p $out/share/nova-shell - cp -r shell.qml modules $out/share/nova-shell/ + cp -r shell.qml modules assets $out/share/nova-shell/ # Compile fragment shader to Qt RHI format qsb --qt6 \ diff --git a/shell.qml b/shell.qml index 81dd953..0f9ac1e 100644 --- a/shell.qml +++ b/shell.qml @@ -1,9 +1,15 @@ //@ pragma Env QS_NO_RELOAD_POPUP=1 import "modules" +import "modules/lock" as Lock import Quickshell ShellRoot { + LazyLoader { + active: Modules.lock.enable + Lock.Lock {} + } + Variants { model: Quickshell.screens