From ab54178747860c9d7177fcde82ed8ae736e6015d Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Fri, 5 Dec 2025 01:25:50 +0100 Subject: [PATCH] lock screen? --- AGENTS.md | 24 +++ Bar.qml | 4 +- Config/Config.qml | 4 + Config/IdleTimeout.qml | 15 ++ Config/LockConf.qml | 14 ++ Helpers/Players.qml | 10 ++ Helpers/SearchWallpapers.qml | 1 + Helpers/WallpaperPath.qml | 3 + Modules/Launcher.qml | 1 + Modules/Lock/IdleInhibitor.qml | 39 +++++ Modules/Lock/Lock.qml | 37 +++++ Modules/Lock/LockSurface.qml | 286 ++++++++++++++++++++++++++++++++ Modules/Lock/Pam.qml | 97 +++++++++++ Modules/NotificationCenter.qml | 1 + Modules/TrackedNotification.qml | 9 +- assets/pam.d/fprint | 3 + assets/pam.d/passwd | 6 + scripts/LockScreenBg.py | 18 ++ shell.qml | 8 + 19 files changed, 574 insertions(+), 6 deletions(-) create mode 100644 AGENTS.md create mode 100644 Config/IdleTimeout.qml create mode 100644 Config/LockConf.qml create mode 100644 Helpers/Players.qml create mode 100644 Modules/Lock/IdleInhibitor.qml create mode 100644 Modules/Lock/Lock.qml create mode 100644 Modules/Lock/LockSurface.qml create mode 100644 Modules/Lock/Pam.qml create mode 100644 assets/pam.d/fprint create mode 100644 assets/pam.d/passwd create mode 100644 scripts/LockScreenBg.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4b70d2e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# Agent Guidelines for z-bar-qt + +## Build & Test +- **Build**: `cmake -B build -G Ninja && ninja -C build` (uses CMake + Ninja) +- **Install**: `sudo ninja -C build install` (installs to /usr/lib/qt6/qml) +- **No test suite**: Project has no automated tests currently +- **Update script**: `scripts/update.sh` (runs `yay -Sy`) + +## Code Style - C++ +- **Standard**: C++20 with strict warnings enabled (see CMakeLists.txt line 14-20) +- **Headers**: `#pragma once` for header guards +- **Types**: Use [[nodiscard]] for getters, explicit constructors, const correctness +- **Qt Integration**: QML_ELEMENT/QML_UNCREATABLE macros, Q_PROPERTY for QML exposure +- **Naming**: camelCase for methods/variables, m_ prefix for member variables +- **Includes**: Qt headers with lowercase (qobject.h, qqmlintegration.h) +- **Namespaces**: Use `namespace ZShell` for plugin code + +## Code Style - QML +- **Pragma**: Start with `pragma ComponentBehavior: Bound` for type safety +- **Imports**: Qt modules first, then Quickshell, then local (qs.Modules, qs.Config, qs.Helpers) +- **Aliases**: Use `qs` prefix for local module imports +- **Properties**: Use `required property` for mandatory bindings +- **Types**: Explicit type annotations in JavaScript (`: void`, `: string`) +- **Structure**: Components in Components/, Modules in Modules/, Config singletons in Config/ diff --git a/Bar.qml b/Bar.qml index 4b32b13..f8a7337 100644 --- a/Bar.qml +++ b/Bar.qml @@ -23,6 +23,8 @@ Scope { screen: modelData color: "transparent" property var root: Quickshell.shellDir + + WlrLayershell.namespace: "ZShell-Bar" WlrLayershell.exclusionMode: ExclusionMode.Ignore PanelWindow { @@ -69,7 +71,7 @@ Scope { Variants { id: popoutRegions model: panels.children - + Region { required property Item modelData diff --git a/Config/Config.qml b/Config/Config.qml index 50c7155..cca5987 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -22,6 +22,8 @@ Singleton { property alias transparency: adapter.transparency property alias baseFont: adapter.baseFont property alias animScale: adapter.animScale + property alias lock: adapter.lock + property alias idle: adapter.idle FileView { id: root @@ -52,6 +54,8 @@ Singleton { property Transparency transparency: Transparency {} property string baseFont: "Segoe UI Variable Text" property real animScale: 1.0 + property LockConf lock: LockConf {} + property IdleTimeout idle: IdleTimeout {} } } } diff --git a/Config/IdleTimeout.qml b/Config/IdleTimeout.qml new file mode 100644 index 0000000..c1db937 --- /dev/null +++ b/Config/IdleTimeout.qml @@ -0,0 +1,15 @@ +import Quickshell.Io + +JsonObject { + property list timeouts: [ + { + timeout: 180, + idleAction: "lock" + }, + { + timeout: 300, + idleAction: "dpms off", + activeAction: "dpms on" + } + ] +} diff --git a/Config/LockConf.qml b/Config/LockConf.qml new file mode 100644 index 0000000..2af4e2c --- /dev/null +++ b/Config/LockConf.qml @@ -0,0 +1,14 @@ +import Quickshell.Io + +JsonObject { + property bool recolourLogo: false + property bool enableFprint: true + property int maxFprintTries: 3 + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property real heightMult: 0.7 + property real ratio: 16 / 9 + property int centerWidth: 600 + } +} diff --git a/Helpers/Players.qml b/Helpers/Players.qml new file mode 100644 index 0000000..68bc112 --- /dev/null +++ b/Helpers/Players.qml @@ -0,0 +1,10 @@ +pragma Singleton + +import Quickshell +import Quickshell.Services.Mpris + +Singleton { + id: root + + readonly property list list: Mpris.players.values +} diff --git a/Helpers/SearchWallpapers.qml b/Helpers/SearchWallpapers.qml index f078521..8eaadb8 100644 --- a/Helpers/SearchWallpapers.qml +++ b/Helpers/SearchWallpapers.qml @@ -19,6 +19,7 @@ Searcher { function setWallpaper(path: string): void { actualCurrent = path; WallpaperPath.currentWallpaperPath = path; + Quickshell.execDetached(["python3", Quickshell.shellPath("scripts/LockScreenBg.py"), `--input_image=${root.actualCurrent}`, `--output_path=${Paths.state}/lockscreen_bg.png`]); } function preview(path: string): void { diff --git a/Helpers/WallpaperPath.qml b/Helpers/WallpaperPath.qml index 2ed553a..4dafb08 100644 --- a/Helpers/WallpaperPath.qml +++ b/Helpers/WallpaperPath.qml @@ -2,11 +2,13 @@ pragma Singleton import Quickshell import Quickshell.Io +import qs.Paths Singleton { id: root property alias currentWallpaperPath: adapter.currentWallpaperPath + property alias lockscreenBg: adapter.lockscreenBg FileView { id: fileView @@ -18,6 +20,7 @@ Singleton { JsonAdapter { id: adapter property string currentWallpaperPath: "" + property string lockscreenBg: `${Paths.state}/lockscreen_bg.png` } } } diff --git a/Modules/Launcher.qml b/Modules/Launcher.qml index 39ef87f..b0ac617 100644 --- a/Modules/Launcher.qml +++ b/Modules/Launcher.qml @@ -22,6 +22,7 @@ Scope { color: "transparent" visible: false + WlrLayershell.namespace: "ZShell-Launcher" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive diff --git a/Modules/Lock/IdleInhibitor.qml b/Modules/Lock/IdleInhibitor.qml new file mode 100644 index 0000000..e4f6214 --- /dev/null +++ b/Modules/Lock/IdleInhibitor.qml @@ -0,0 +1,39 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Wayland +import qs.Config +import qs.Helpers + +Scope { + id: root + + required property Lock lock + readonly property bool enabled: !Players.list.some( p => p.isPlaying ) + + function handleIdleAction( action: var ): void { + if ( !action ) + return; + + if ( action === "lock" ) + lock.lock.locked = true; + else if ( action === "unlock" ) + lock.lock.locked = false; + else if ( typeof action === "string" ) + Hypr.dispatch( action ); + else + Quickshell.execDetached( action ); + } + + Variants { + model: Config.idle.timeouts + + IdleMonitor { + required property var modelData + + enabled: root.enabled && modelData.timeout > 0 ? true : false + timeout: modelData.timeout + onIsIdleChanged: root.handleIdleAction( isIdle ? modelData.idleAction : modelData.activeAction ) + } + } +} diff --git a/Modules/Lock/Lock.qml b/Modules/Lock/Lock.qml new file mode 100644 index 0000000..3a6338c --- /dev/null +++ b/Modules/Lock/Lock.qml @@ -0,0 +1,37 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick + +Scope { + id: root + property alias lock: lock + + WlSessionLock { + id: lock + + signal unlock + + LockSurface { + lock: lock + pam: pam + } + } + + Pam { + id: pam + + lock: lock + } + + GlobalShortcut { + name: "lock" + description: "Lock the current session" + appid: "zshell-lock" + onPressed: { + lock.locked = true + } + } +} diff --git a/Modules/Lock/LockSurface.qml b/Modules/Lock/LockSurface.qml new file mode 100644 index 0000000..6b578f1 --- /dev/null +++ b/Modules/Lock/LockSurface.qml @@ -0,0 +1,286 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Wayland +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Effects +import qs.Config +import qs.Helpers +import qs.Effects +import qs.Components +import qs.Modules + +WlSessionLockSurface { + id: root + + required property WlSessionLock lock + required property Pam pam + + color: "transparent" + + TextInput { + id: hiddenInput + focus: true + visible: false + + Keys.onPressed: function(event: KeyEvent): void { + root.pam.handleKey(event); + event.accepted = true; + } + + onTextChanged: text = "" + } + + ScreencopyView { + id: background + + anchors.fill: parent + captureSource: root.screen + opacity: 1 + + layer.enabled: true + layer.effect: MultiEffect { + autoPaddingEnabled: false + blurEnabled: true + blur: 2 + blurMax: 32 + blurMultiplier: 0 + } + } + + // Image { + // id: backgroundImage + // anchors.fill: parent + // asynchronous: true + // source: WallpaperPath.lockscreenBg + // } + + Rectangle { + anchors.fill: parent + color: "transparent" + + Rectangle { + id: contentBox + anchors.centerIn: parent + + // Material Design 3: Use surfaceContainer for elevated surfaces + color: DynamicColors.tPalette.m3surfaceContainer + radius: 28 + + // M3 spacing: 24px horizontal, 32px vertical padding + implicitWidth: Math.floor(childrenRect.width + 48) + implicitHeight: Math.floor(childrenRect.height + 64) + + // M3 Elevation 2 shadow effect + layer.enabled: true + layer.effect: MultiEffect { + source: contentBox + blurEnabled: false + blurMax: 12 + shadowBlur: 1 + shadowColor: DynamicColors.palette.m3shadow + shadowOpacity: 0.3 + shadowEnabled: true + autoPaddingEnabled: true + } + + ColumnLayout { + id: mainLayout + anchors.centerIn: parent + width: childrenRect.width + spacing: 0 + + // Title: M3 Display Small (32sp, 400 weight) + Text { + id: titleText + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: 8 + + text: "Session Locked" + font.pixelSize: 32 + font.weight: Font.Normal + font.letterSpacing: 0.5 + color: DynamicColors.palette.m3onSurface + } + + // Support text: M3 Body Medium (14sp, 500 weight) + Text { + id: supportText + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: 24 + + text: "Please enter your password to unlock" + font.pixelSize: 14 + font.weight: Font.Medium + color: DynamicColors.palette.m3onSurfaceVariant + } + + // Input field container + Rectangle { + id: inputContainer + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: 16 + + Layout.preferredWidth: 320 + Layout.preferredHeight: 48 + + color: DynamicColors.tPalette.m3surfaceContainerHigh + radius: 1000 + + border.width: 1 + border.color: { + if (root.pam.state === "error" || root.pam.state === "fail") { + return DynamicColors.palette.m3error; + } + return DynamicColors.palette.m3outline; + } + + Behavior on border.color { + ColorAnimation { duration: 150 } + } + + ListView { + id: charList + + readonly property int fullWidth: count * (implicitHeight + spacing) - spacing + + function bindImWidth(): void { + imWidthBehavior.enabled = false; + implicitWidth = Qt.binding(() => fullWidth); + imWidthBehavior.enabled = true; + } + + anchors.centerIn: parent + anchors.horizontalCenterOffset: implicitWidth > root.width ? -(implicitWidth - root.width) / 2 : 0 + + implicitWidth: fullWidth + implicitHeight: 16 + + orientation: Qt.Horizontal + spacing: 8 + interactive: false + + model: ScriptModel { + values: root.pam.buffer.split("") + } + + delegate: CustomRect { + id: ch + + implicitWidth: implicitHeight + implicitHeight: charList.implicitHeight + + color: DynamicColors.palette.m3onSurface + radius: 1000 + + opacity: 0 + scale: 0 + Component.onCompleted: { + opacity = 1; + scale = 1; + } + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: true + } + ParallelAnimation { + Anim { + target: ch + property: "opacity" + to: 0 + } + Anim { + target: ch + property: "scale" + to: 0.5 + } + } + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: false + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: MaterialEasing.expressiveFastSpatialTime + easing.bezierCurve: MaterialEasing.expressiveFastSpatial + } + } + } + + Behavior on implicitWidth { + id: imWidthBehavior + + Anim {} + } + } + + // Input focus indicator (M3 focused state) + Rectangle { + anchors.fill: parent + radius: 12 + color: "transparent" + border.width: 2 + border.color: DynamicColors.palette.m3primary + opacity: 0 + visible: hiddenInput.activeFocus + + Behavior on opacity { + NumberAnimation { duration: 150 } + } + } + + Component.onCompleted: { + if (hiddenInput.activeFocus) opacity = 1; + } + } + + // Message display: M3 Body Small (12sp, 500 weight) for error messages + Text { + id: messageDisplay + + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 8 + + text: { + if (root.pam.lockMessage) { + return root.pam.lockMessage; + } + if (root.pam.state === "error") { + return "Authentication error"; + } + if (root.pam.state === "fail") { + return "Invalid password"; + } + if (root.pam.state === "max") { + return "Maximum attempts reached"; + } + return ""; + } + visible: text.length > 0 + font.pixelSize: 12 + font.weight: Font.Medium + color: root.pam.state === "max" ? DynamicColors.palette.m3error : DynamicColors.palette.m3onSurfaceVariant + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + Layout.preferredWidth: 320 + } + } + } + Component.onCompleted: hiddenInput.forceActiveFocus() + } +} diff --git a/Modules/Lock/Pam.qml b/Modules/Lock/Pam.qml new file mode 100644 index 0000000..47dd541 --- /dev/null +++ b/Modules/Lock/Pam.qml @@ -0,0 +1,97 @@ +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Pam +import QtQuick + +Scope { + id: root + + required property WlSessionLock lock + + readonly property alias passwd: passwd + property string lockMessage + property string state + property string fprintState + property string buffer + + signal flashMsg + + function handleKey(event: KeyEvent): void { + if (passwd.active || state === "max") + return; + + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + 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 (" abcdefghijklmnopqrstuvwxyz1234567890`~!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?".includes(event.text.toLowerCase())) { + buffer += event.text; + } + } + + PamContext { + id: passwd + + config: "passwd" + configDirectory: Quickshell.shellDir + "/assets/pam.d" + + onMessageChanged: { + if ( message.startsWith( "The account is locked" )) + root.lockMessage = message; + else if ( root.lockMessage && message.endsWith( " left to unlock)" )) + root.lockMessage += "\n" + message; + } + + onResponseRequiredChanged: { + if ( !responseRequired ) + return; + + respond(root.buffer); + root.buffer = ""; + } + + onCompleted: res => { + if (res === PamResult.Success) + return root.lock.locked = false; + + if (res === PamResult.Error) + root.state = "error"; + else if (res === PamResult.MaxTries) + root.state = "max"; + else if (res === PamResult.Failed) + root.state = "fail"; + + root.flashMsg(); + stateReset.restart(); + } + } + + Timer { + id: stateReset + + interval: 4000 + onTriggered: { + if (root.state !== "max") + root.state = ""; + } + } + + Connections { + target: root.lock + + function onSecureChanged(): void { + if (root.lock.secure) { + root.buffer = ""; + root.state = ""; + root.fprintState = ""; + root.lockMessage = ""; + } + } + } +} diff --git a/Modules/NotificationCenter.qml b/Modules/NotificationCenter.qml index 743f7ac..abe1889 100644 --- a/Modules/NotificationCenter.qml +++ b/Modules/NotificationCenter.qml @@ -21,6 +21,7 @@ PanelWindow { bottom: true } + WlrLayershell.namespace: "ZShell-Notifs" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand required property PanelWindow bar diff --git a/Modules/TrackedNotification.qml b/Modules/TrackedNotification.qml index c810874..f596b60 100644 --- a/Modules/TrackedNotification.qml +++ b/Modules/TrackedNotification.qml @@ -13,6 +13,7 @@ PanelWindow { id: root color: "transparent" screen: root.bar.screen + anchors { top: true right: true @@ -20,9 +21,11 @@ PanelWindow { bottom: true } + WlrLayershell.namespace: "ZShell-Notifs" + WlrLayershell.layer: WlrLayer.Overlay + mask: Region { regions: root.notifRegions } exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Overlay property list notifRegions: [] required property bool centerShown required property PanelWindow bar @@ -30,10 +33,6 @@ PanelWindow { property color backgroundColor: Config.useDynamicColors ? DynamicColors.tPalette.m3surface : Config.baseBgColor visible: Hyprland.monitorFor(screen).focused - Component.onCompleted: { - console.log(NotifServer.list.filter( n => n.popup ).length + " notification popups loaded."); - } - ListView { id: notifListView model: ScriptModel { diff --git a/assets/pam.d/fprint b/assets/pam.d/fprint new file mode 100644 index 0000000..27804f3 --- /dev/null +++ b/assets/pam.d/fprint @@ -0,0 +1,3 @@ +auth required pam_fprintd.so +auth include common-auth +account include common-account diff --git a/assets/pam.d/passwd b/assets/pam.d/passwd new file mode 100644 index 0000000..4b14064 --- /dev/null +++ b/assets/pam.d/passwd @@ -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/scripts/LockScreenBg.py b/scripts/LockScreenBg.py new file mode 100644 index 0000000..5ccc5b1 --- /dev/null +++ b/scripts/LockScreenBg.py @@ -0,0 +1,18 @@ +from PIL import Image, ImageFilter +import argparse + +def gen_blurred_image(input_image, output_path): + img = Image.open(input_image) + + img = img.filter(ImageFilter.GaussianBlur(40)) + + img.save(output_path, "PNG") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate a blurred lock screen background image.") + parser.add_argument("--input_image", type=str) + parser.add_argument("--output_path", type=str) + + args = parser.parse_args() + + gen_blurred_image(args.input_image, args.output_path) diff --git a/shell.qml b/shell.qml index 2a7f1ae..05aaf97 100644 --- a/shell.qml +++ b/shell.qml @@ -2,6 +2,7 @@ //@ pragma Env QSG_RENDER_LOOP=threaded import Quickshell import qs.Modules +import qs.Modules.Lock import qs.Helpers Scope { @@ -9,4 +10,11 @@ Scope { Wallpaper {} Launcher {} AreaPicker {} + Lock { + id: lock + } + + IdleInhibitor { + lock: lock + } }