From 5e32e4309165bee7ede6a884e156ce7631354c42 Mon Sep 17 00:00:00 2001 From: Sho Date: Sun, 2 Aug 2026 16:12:37 +0900 Subject: [PATCH] feat(lockscreen): add system-themed elegant quickshell lockscreen with PAM authentication and animated letter typing indicator --- .../modules/lockscreen/LockscreenContent.qml | 461 ++++++++++++++++++ .../modules/lockscreen/LockscreenWindow.qml | 50 ++ quickshell/services/LockscreenService.qml | 110 +++++ quickshell/services/SessionService.qml | 1 + .../python/lockscreen_auth_service.py | 75 +++ quickshell/shell.qml | 4 + 6 files changed, 701 insertions(+) create mode 100644 quickshell/modules/lockscreen/LockscreenContent.qml create mode 100644 quickshell/modules/lockscreen/LockscreenWindow.qml create mode 100644 quickshell/services/LockscreenService.qml create mode 100755 quickshell/services/python/lockscreen_auth_service.py diff --git a/quickshell/modules/lockscreen/LockscreenContent.qml b/quickshell/modules/lockscreen/LockscreenContent.qml new file mode 100644 index 0000000..c612250 --- /dev/null +++ b/quickshell/modules/lockscreen/LockscreenContent.qml @@ -0,0 +1,461 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import "../../theme" +import "../../services" + +Item { + id: root + + focus: true + + // Force focus when lockscreen becomes visible + onVisibleChanged: { + if (visible) { + root.forceActiveFocus() + secretInput.forceActiveFocus() + } + } + + Component.onCompleted: { + root.forceActiveFocus() + secretInput.forceActiveFocus() + } + + // Secret hidden TextInput to capture keyboard presses reliably + TextInput { + id: secretInput + anchors.fill: parent + opacity: 0 + focus: true + activeFocusOnTab: true + echoMode: TextInput.Password + + onTextChanged: { + // Synchronize with LockscreenService + if (text !== LockscreenService.userPassword) { + LockscreenService.userPassword = text + LockscreenService.typedCount = text.length + } + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + event.accepted = true + LockscreenService.submitPassword() + } else if (event.key === Qt.Key_Escape) { + event.accepted = true + secretInput.text = "" + LockscreenService.clearPassword() + } + } + } + + Connections { + target: LockscreenService + function onIsLockedChanged() { + if (LockscreenService.isLocked) { + secretInput.text = "" + root.forceActiveFocus() + secretInput.forceActiveFocus() + } + } + function onAuthFailedChanged() { + if (LockscreenService.authFailed) { + shakeAnim.restart() + secretInput.text = "" + } + } + } + + // Shake animation on incorrect password + SequentialAnimation { + id: shakeAnim + running: false + NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: -24; duration: 40; easing.type: Easing.OutQuad } + NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: 24; duration: 50; easing.type: Easing.OutQuad } + NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: -16; duration: 50; easing.type: Easing.OutQuad } + NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: 16; duration: 50; easing.type: Easing.OutQuad } + NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: -8; duration: 40; easing.type: Easing.OutQuad } + NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: 8; duration: 40; easing.type: Easing.OutQuad } + NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: 0; duration: 40; easing.type: Easing.OutQuad } + } + + // Click anywhere to focus input + MouseArea { + anchors.fill: parent + onClicked: { + root.forceActiveFocus() + secretInput.forceActiveFocus() + } + } + + // Main Centered Content Container + ColumnLayout { + id: mainColumn + anchors.centerIn: parent + spacing: 28 + width: Math.min(520, parent.width - 40) + + // 1. Clock & Date Header + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 6 + + Text { + id: clockText + text: { + let date = new Date() + let h = date.getHours().toString().padStart(2, '0') + let m = date.getMinutes().toString().padStart(2, '0') + let s = date.getSeconds().toString().padStart(2, '0') + return `${h}:${m}:${s}` + } + color: Theme.fg + font.pixelSize: 64 + font.weight: Font.Bold + font.family: "Sans-Serif" + Layout.alignment: Qt.AlignHCenter + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: clockText.text = { + let date = new Date() + let h = date.getHours().toString().padStart(2, '0') + let m = date.getMinutes().toString().padStart(2, '0') + let s = date.getSeconds().toString().padStart(2, '0') + return `${h}:${m}:${s}` + } + } + } + + Text { + id: dateText + text: { + let date = new Date() + let options = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' } + return date.toLocaleDateString(Qt.locale(), options) + } + color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.75) + font.pixelSize: 15 + font.weight: Font.Medium + Layout.alignment: Qt.AlignHCenter + } + } + + Item { Layout.preferredHeight: 10 } + + // 2. User Avatar & Badge + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 12 + + Rectangle { + width: 96 + height: 96 + radius: 48 + color: Qt.rgba(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.15) + border.color: Theme.accent + border.width: 2 + Layout.alignment: Qt.AlignHCenter + + // Avatar Icon / Initial Badge + Text { + anchors.centerIn: parent + text: LockscreenService.username ? LockscreenService.username.charAt(0).toUpperCase() : "🔒" + color: Theme.accent + font.pixelSize: 42 + font.weight: Font.Bold + } + + // Breathing Glow Ring + Rectangle { + anchors.fill: parent + anchors.margins: -4 + radius: 52 + color: "transparent" + border.color: Theme.accent + border.width: 1.5 + opacity: avatarPulse.opacityVal + + Item { + id: avatarPulse + property real opacityVal: 0.3 + SequentialAnimation on opacityVal { + loops: Animation.Infinite + running: true + NumberAnimation { to: 0.8; duration: 1500; easing.type: Easing.InOutQuad } + NumberAnimation { to: 0.2; duration: 1500; easing.type: Easing.InOutQuad } + } + } + } + } + + Text { + text: LockscreenService.username || "User" + color: Theme.fg + font.pixelSize: 20 + font.weight: Font.Bold + Layout.alignment: Qt.AlignHCenter + } + } + + Item { Layout.preferredHeight: 4 } + + // 3. Elegant Glass Input Card with Animated Typing Dots + Rectangle { + id: cardContainer + Layout.fillWidth: true + implicitHeight: cardLayout.implicitHeight + 36 + radius: 20 + color: Theme.isDark ? Qt.rgba(30/255, 30/255, 46/255, 0.65) : Qt.rgba(240/255, 240/255, 245/255, 0.75) + border.color: LockscreenService.authFailed ? Theme.red : (secretInput.activeFocus ? Theme.accent : Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.18)) + border.width: LockscreenService.authFailed ? 2 : (secretInput.activeFocus ? 2 : 1) + + Behavior on border.color { ColorAnimation { duration: 180 } } + + ColumnLayout { + id: cardLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 18 + spacing: 14 + + // Password Row: Input Field + Submit Button + RowLayout { + Layout.fillWidth: true + spacing: 12 + + // Lock Icon + Text { + text: "🔒" + font.pixelSize: 18 + Layout.alignment: Qt.AlignVCenter + } + + // Password Visual Placeholder / Input Area + Rectangle { + Layout.fillWidth: true + implicitHeight: 44 + radius: 12 + color: Qt.rgba(0, 0, 0, 0.25) + border.color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.1) + + // Empty State Placeholder Text + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 16 + visible: LockscreenService.typedCount === 0 && !LockscreenService.isAuthenticating + text: "Type password to unlock..." + color: Theme.comment + font.pixelSize: 14 + } + + // Authenticating Spinner / Status Text + Text { + anchors.centerIn: parent + visible: LockscreenService.isAuthenticating + text: "Verifying password..." + color: Theme.accent + font.pixelSize: 14 + font.weight: Font.Medium + } + + // ANIMATED TYPING DOTS (Shows WHEN and HOW MANY letters are typed) + Row { + anchors.centerIn: parent + spacing: 10 + visible: LockscreenService.typedCount > 0 && !LockscreenService.isAuthenticating + + Repeater { + model: LockscreenService.typedCount + + Rectangle { + id: dot + width: 14 + height: 14 + radius: 7 + color: index === LockscreenService.typedCount - 1 ? Theme.accent : Theme.fg + + Behavior on color { ColorAnimation { duration: 120 } } + + // Spawn Bounce & Pulse Animation for each character typed + scale: 1.0 + SequentialAnimation on scale { + running: true + NumberAnimation { from: 0.2; to: 1.4; duration: 120; easing.type: Easing.OutQuad } + NumberAnimation { from: 1.4; to: 1.0; duration: 100; easing.type: Easing.OutBounce } + } + + // Inner Glow Core + Rectangle { + anchors.centerIn: parent + width: 6 + height: 6 + radius: 3 + color: "#ffffff" + } + } + } + } + + // Focus Click Area + MouseArea { + anchors.fill: parent + onClicked: { + root.forceActiveFocus() + secretInput.forceActiveFocus() + } + } + } + + // Submit / Unlock Arrow Button + Rectangle { + width: 44 + height: 44 + radius: 12 + color: submitMouse.containsMouse ? Theme.accent : Qt.rgba(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.25) + border.color: Theme.accent + + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + anchors.centerIn: parent + text: "➔" + color: submitMouse.containsMouse ? "#ffffff" : Theme.accent + font.pixelSize: 18 + font.weight: Font.Bold + } + + MouseArea { + id: submitMouse + anchors.fill: parent + hoverEnabled: true + onClicked: LockscreenService.submitPassword() + } + } + } + + // Dynamic Typing Status / Letter Counter & Feedback Message + RowLayout { + Layout.fillWidth: true + + // Typing Letter Counter Badge (Shows EXACT count of letters typed) + Rectangle { + implicitWidth: counterText.implicitWidth + 16 + implicitHeight: 24 + radius: 12 + color: LockscreenService.typedCount > 0 ? Qt.rgba(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.2) : "transparent" + visible: LockscreenService.typedCount > 0 + + Behavior on color { ColorAnimation { duration: 150 } } + + Text { + id: counterText + anchors.centerIn: parent + text: `⌨️ ${LockscreenService.typedCount} ${LockscreenService.typedCount === 1 ? 'letter' : 'letters'} typed` + color: Theme.accent + font.pixelSize: 11 + font.weight: Font.Bold + } + } + + Item { Layout.fillWidth: true } + + // Auth Error Message Display + Text { + text: LockscreenService.authErrorMsg + color: Theme.red + font.pixelSize: 12 + font.weight: Font.Bold + visible: LockscreenService.authFailed + } + } + } + } + + Item { Layout.preferredHeight: 14 } + + // 4. Session Action Buttons (Power & Unlock) + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 16 + + // Suspend Button + Rectangle { + implicitWidth: 110 + implicitHeight: 38 + radius: 10 + color: suspendMouse.containsMouse ? Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.15) : Qt.rgba(0, 0, 0, 0.3) + border.color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.15) + + RowLayout { + anchors.centerIn: parent + spacing: 6 + Text { text: "🌙"; font.pixelSize: 13 } + Text { text: "Sleep"; color: Theme.fg; font.pixelSize: 12; font.weight: Font.Medium } + } + + MouseArea { + id: suspendMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + Quickshell.execDetached(["systemctl", "suspend"]) + } + } + } + + // Reboot Button + Rectangle { + implicitWidth: 110 + implicitHeight: 38 + radius: 10 + color: rebootMouse.containsMouse ? Qt.rgba(Theme.orange.r, Theme.orange.g, Theme.orange.b, 0.25) : Qt.rgba(0, 0, 0, 0.3) + border.color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.15) + + RowLayout { + anchors.centerIn: parent + spacing: 6 + Text { text: "🔄"; font.pixelSize: 13 } + Text { text: "Restart"; color: Theme.fg; font.pixelSize: 12; font.weight: Font.Medium } + } + + MouseArea { + id: rebootMouse + anchors.fill: parent + hoverEnabled: true + onClicked: SessionService.reboot() + } + } + + // Shutdown Button + Rectangle { + implicitWidth: 110 + implicitHeight: 38 + radius: 10 + color: powerMouse.containsMouse ? Qt.rgba(Theme.red.r, Theme.red.g, Theme.red.b, 0.25) : Qt.rgba(0, 0, 0, 0.3) + border.color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.15) + + RowLayout { + anchors.centerIn: parent + spacing: 6 + Text { text: "⚡"; font.pixelSize: 13 } + Text { text: "Power Off"; color: Theme.fg; font.pixelSize: 12; font.weight: Font.Medium } + } + + MouseArea { + id: powerMouse + anchors.fill: parent + hoverEnabled: true + onClicked: SessionService.shutdown() + } + } + } + } +} diff --git a/quickshell/modules/lockscreen/LockscreenWindow.qml b/quickshell/modules/lockscreen/LockscreenWindow.qml new file mode 100644 index 0000000..b25102a --- /dev/null +++ b/quickshell/modules/lockscreen/LockscreenWindow.qml @@ -0,0 +1,50 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import "../../theme" +import "../../services" + +PanelWindow { + id: lockscreenWindow + + anchors { + top: true + bottom: true + left: true + right: true + } + + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.keyboardFocus: LockscreenService.isLocked ? WlrLayershell.Exclusive : WlrLayershell.None + exclusionMode: ExclusionMode.Ignore + + visible: LockscreenService.isLocked + color: "black" + + // Background System Wallpaper + Image { + anchors.fill: parent + source: WallpaperService.currentWallpaper + fillMode: Image.PreserveAspectCrop + opacity: 0.65 + + Behavior on source { + PropertyAnimation { duration: 300 } + } + } + + // Glass Dark Backdrop Overlay with Gradient + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Theme.isDark ? Qt.rgba(15/255, 15/255, 25/255, 0.82) : Qt.rgba(20/255, 20/255, 30/255, 0.70) } + GradientStop { position: 0.5; color: Theme.isDark ? Qt.rgba(20/255, 20/255, 32/255, 0.88) : Qt.rgba(25/255, 25/255, 38/255, 0.78) } + GradientStop { position: 1.0; color: Theme.isDark ? Qt.rgba(10/255, 10/255, 18/255, 0.92) : Qt.rgba(15/255, 15/255, 25/255, 0.85) } + } + } + + // Lockscreen Content Layout + LockscreenContent { + anchors.fill: parent + } +} diff --git a/quickshell/services/LockscreenService.qml b/quickshell/services/LockscreenService.qml new file mode 100644 index 0000000..a491896 --- /dev/null +++ b/quickshell/services/LockscreenService.qml @@ -0,0 +1,110 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: root + + property bool isLocked: false + property string userPassword: "" + property bool isAuthenticating: false + property bool authFailed: false + property string authErrorMsg: "" + property int typedCount: 0 + property string username: "" + property string realName: "" + + // Signal emitted whenever a key is typed to trigger animated dot entry/bounce + signal keyTyped(string char) + signal keyDeleted() + + Timer { + id: resetErrorTimer + interval: 2000 + repeat: false + onTriggered: { + root.authFailed = false + root.authErrorMsg = "" + } + } + + Component.onCompleted: { + usernameProc.running = true + } + + Process { + id: usernameProc + command: ["whoami"] + stdout: SplitParser { + onRead: data => { + let u = data.trim() + if (u !== "") root.username = u + } + } + } + + function lock() { + userPassword = "" + typedCount = 0 + authFailed = false + authErrorMsg = "" + isLocked = true + } + + function unlock() { + userPassword = "" + typedCount = 0 + authFailed = false + authErrorMsg = "" + isLocked = false + } + + function appendChar(ch) { + if (isAuthenticating) return; + authFailed = false + userPassword += ch + typedCount = userPassword.length + root.keyTyped(ch) + } + + function backspace() { + if (isAuthenticating) return; + authFailed = false + if (userPassword.length > 0) { + userPassword = userPassword.substring(0, userPassword.length - 1) + typedCount = userPassword.length + root.keyDeleted() + } + } + + function clearPassword() { + userPassword = "" + typedCount = 0 + } + + function submitPassword() { + if (isAuthenticating || userPassword === "") return; + isAuthenticating = true + authFailed = false + authErrorMsg = "" + + authProc.command = ["python3", Quickshell.env("HOME") + "/.config/quickshell/services/python/lockscreen_auth_service.py", userPassword] + authProc.running = true + } + + Process { + id: authProc + onExited: (code, exitStatus) => { + root.isAuthenticating = false + if (code === 0) { + root.unlock() + } else { + root.authFailed = true + root.authErrorMsg = "Incorrect password" + root.clearPassword() + resetErrorTimer.restart() + } + } + } +} diff --git a/quickshell/services/SessionService.qml b/quickshell/services/SessionService.qml index 845cb38..26f845f 100644 --- a/quickshell/services/SessionService.qml +++ b/quickshell/services/SessionService.qml @@ -7,6 +7,7 @@ Item { id: root function lock() { + LockscreenService.lock() lockProc.running = true } diff --git a/quickshell/services/python/lockscreen_auth_service.py b/quickshell/services/python/lockscreen_auth_service.py new file mode 100755 index 0000000..3cd5dde --- /dev/null +++ b/quickshell/services/python/lockscreen_auth_service.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import sys +import os +import ctypes +import ctypes.util +import getpass + +libpam = None +try: + libpam_path = ctypes.util.find_library('pam') or 'libpam.so.0' + libpam = ctypes.CDLL(libpam_path) +except Exception: + libpam = None + +libc = ctypes.CDLL(None) + +class PamMessage(ctypes.Structure): + _fields_ = [('msg_style', ctypes.c_int), ('msg', ctypes.c_char_p)] + +class PamResponse(ctypes.Structure): + _fields_ = [('resp', ctypes.c_char_p), ('resp_retcode', ctypes.c_int)] + +CONV_FUNC = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.POINTER(PamMessage)), ctypes.POINTER(ctypes.POINTER(PamResponse)), ctypes.c_void_p) + +class PamConv(ctypes.Structure): + _fields_ = [('conv', CONV_FUNC), ('appdata_ptr', ctypes.c_void_p)] + +def verify_password(username, password): + if not password: + return False + if not libpam: + return False + + pwd_bytes = password.encode('utf-8') + + def conv_cb(num_msg, msg, resp, appdata_ptr): + response_array = (PamResponse * num_msg)() + for i in range(num_msg): + response_array[i].resp = libc.strdup(pwd_bytes) + response_array[i].resp_retcode = 0 + resp[0] = ctypes.cast(response_array, ctypes.POINTER(PamResponse)) + return 0 + + cb = CONV_FUNC(conv_cb) + conv = PamConv(cb, None) + + for service in ['system-auth', 'login', 'passwd', 'kde', 'sddm', 'gdm', 'su', 'shadow']: + pamh = ctypes.c_void_p() + res = libpam.pam_start(service.encode('utf-8'), username.encode('utf-8'), ctypes.byref(conv), ctypes.byref(pamh)) + if res == 0: + auth_res = libpam.pam_authenticate(pamh, 0) + libpam.pam_end(pamh, auth_res) + if auth_res == 0: + return True + return False + +def main(): + if len(sys.argv) < 2: + # Read from stdin + password = sys.stdin.read().strip() + else: + password = sys.argv[1].strip() + + username = getpass.getuser() + if len(sys.argv) >= 3: + username = sys.argv[2].strip() + + success = verify_password(username, password) + if success: + sys.exit(0) + else: + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/quickshell/shell.qml b/quickshell/shell.qml index 9362bdf..fd6004a 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -7,6 +7,7 @@ import "modules/launcher" import "modules/wallpaper" import "modules/notifications" import "modules/desktop" +import "modules/lockscreen" import "services" Scope { @@ -144,4 +145,7 @@ Scope { // Bottom-Right Notification Toast Overlay NotificationToast {} + + // Lockscreen Overlay Window (Layer: Overlay) + LockscreenWindow {} }