diff --git a/Greeter/Center.qml b/Greeter/Center.qml index ad836af..3c9711a 100644 --- a/Greeter/Center.qml +++ b/Greeter/Center.qml @@ -81,7 +81,7 @@ ColumnLayout { id: pfp anchors.fill: parent - path: `${Paths.home}/.face` + path: root.greeter.userFace } } diff --git a/Greeter/Content.qml b/Greeter/Content.qml index dde52f5..6ebf790 100644 --- a/Greeter/Content.qml +++ b/Greeter/Content.qml @@ -30,17 +30,17 @@ RowLayout { } } - CustomRect { - Layout.fillWidth: true - color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: resources.implicitHeight - radius: Appearance.rounding.small - - Resources { - id: resources - - } - } + // CustomRect { + // Layout.fillWidth: true + // color: DynamicColors.tPalette.m3surfaceContainer + // implicitHeight: resources.implicitHeight + // radius: Appearance.rounding.small + // + // Resources { + // id: resources + // + // } + // } CustomClippingRect { Layout.fillHeight: true @@ -48,6 +48,10 @@ RowLayout { bottomLeftRadius: Appearance.rounding.large color: DynamicColors.tPalette.m3surfaceContainer radius: Appearance.rounding.small + + UserDock { + greeter: root.greeter + } } } diff --git a/Greeter/GreeterState.qml b/Greeter/GreeterState.qml index 047c6e9..d74bb3c 100644 --- a/Greeter/GreeterState.qml +++ b/Greeter/GreeterState.qml @@ -4,6 +4,7 @@ import Quickshell import Quickshell.Services.Greetd import Quickshell.Io import QtQuick +import qs.Helpers Scope { id: root @@ -17,7 +18,25 @@ Scope { readonly property var selectedSession: sessionIndex >= 0 ? sessions[sessionIndex] : null property int sessionIndex: sessions.length > 0 ? 0 : -1 property var sessions: [] - required property string username + + // User handling - now uses the Users singleton + readonly property var users: Users.users + readonly property var selectedUser: Users.selectedUser + readonly property string username: Users.selectedUsername + readonly property string userFace: selectedUser ? selectedUser.face : "" + + // User selection functions (delegate to Users singleton) + function selectUser(username: string): bool { + return Users.selectUser(username); + } + + function selectNextUser(): void { + Users.selectNext(); + } + + function selectPreviousUser(): void { + Users.selectPrevious(); + } signal flashMsg @@ -55,6 +74,9 @@ Scope { return; } + // Save the current user as the default for next login + Users.saveDefaultUser(); + launching = true; Greetd.launch(selectedSession.command, [], true); } diff --git a/Greeter/Helpers/Users.qml b/Greeter/Helpers/Users.qml new file mode 100644 index 0000000..d2ffdc9 --- /dev/null +++ b/Greeter/Helpers/Users.qml @@ -0,0 +1,112 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + // The list of users that can log in graphically + // Each user object has: username, uid, home, shell, gecos (full name), face (avatar path) + property var users: [] + + // The currently selected user index + property int selectedIndex: 0 + + // The currently selected user object (or null if none) + readonly property var selectedUser: selectedIndex >= 0 && selectedIndex < users.length ? users[selectedIndex] : null + + // Convenience property for the selected username + readonly property string selectedUsername: selectedUser ? selectedUser.username : "" + + // Path to store the default user preference + readonly property string defaultUserFile: "/etc/zshell-greeter/default-user" + + // Select a user by username + function selectUser(username: string): bool { + for (let i = 0; i < users.length; i++) { + if (users[i].username === username) { + selectedIndex = i; + return true; + } + } + return false; + } + + // Select the next user in the list (wraps around) + function selectNext(): void { + if (users.length === 0) + return; + selectedIndex = (selectedIndex + 1) % users.length; + } + + // Select the previous user in the list (wraps around) + function selectPrevious(): void { + if (users.length === 0) + return; + selectedIndex = (selectedIndex - 1 + users.length) % users.length; + } + + // Save the current user as the default for next login + function saveDefaultUser(): void { + if (selectedUser) { + defaultUserStorage.setText(selectedUser.username); + } + } + + // Process to fetch the list of graphical users + Process { + id: userLister + + command: ["python3", Quickshell.shellDir + "/scripts/get-users"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + try { + root.users = JSON.parse(text); + + // If we have users and no selection yet, try to select the default user + if (root.users.length > 0) { + // Try to load the default user + if (defaultUserStorage.loaded) { + const defaultUsername = defaultUserStorage.text().trim(); + if (defaultUsername && !root.selectUser(defaultUsername)) { + // Default user not found, select first user + root.selectedIndex = 0; + } + } else { + root.selectedIndex = 0; + } + } + } catch (e) { + console.error("Failed to parse users:", e); + } + } + } + } + + // FileView for persisting the default user + FileView { + id: defaultUserStorage + + path: root.defaultUserFile + preload: true + + onLoaded: { + // If users are already loaded, try to select the default user + if (root.users.length > 0) { + const defaultUsername = text().trim(); + if (defaultUsername) { + root.selectUser(defaultUsername); + } + } + } + + onLoadFailed: { + // File doesn't exist yet, that's fine - we'll create it on first save + console.log("No default user file found, will use first user"); + } + } +} diff --git a/Greeter/UserDock.qml b/Greeter/UserDock.qml new file mode 100644 index 0000000..65050b3 --- /dev/null +++ b/Greeter/UserDock.qml @@ -0,0 +1,136 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Helpers +import qs.Config + +ColumnLayout { + id: root + + required property var greeter + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.smaller + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font.family: Appearance.font.family.mono + font.weight: 500 + text: root.greeter.users.length > 0 ? qsTr("%1 user%2").arg(root.greeter.users.length).arg(root.greeter.users.length === 1 ? "" : "s") : qsTr("Users") + } + + CustomClippingRect { + Layout.fillHeight: true + Layout.fillWidth: true + color: "transparent" + radius: Appearance.rounding.small + + Loader { + active: opacity > 0 + anchors.centerIn: parent + opacity: root.greeter.users.length > 0 ? 0 : 1 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.extraLarge + } + } + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.large + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3outlineVariant + fill: 1 + font.pointSize: Appearance.font.size.extraLarge * 2 + text: "account_circle" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3outlineVariant + font.family: Appearance.font.family.mono + font.pointSize: Appearance.font.size.large + font.weight: 500 + text: qsTr("No Users Found") + } + } + } + + ListView { + id: users + + anchors.fill: parent + clip: true + highlightFollowsCurrentItem: false + model: root.greeter.users + spacing: Appearance.spacing.small + + delegate: CustomRect { + id: user + + required property int index + required property var modelData + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: row.implicitHeight + Appearance.padding.normal * 2 + radius: Appearance.rounding.normal - Appearance.padding.smaller + + StateLayer { + function onClicked(): void { + users.currentIndex = index; + } + } + + RowLayout { + id: row + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.normal + + MaterialIcon { + color: user.index === users.currentIndex ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurfaceVariant + fill: 1 + font.pointSize: Appearance.font.size.extraLarge + text: modelData.kind === "x11" ? "tv" : "account_circle" + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small / 2 + + CustomText { + Layout.fillWidth: true + color: user.index === users.currentIndex ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + elide: Text.ElideRight + font.pointSize: Appearance.font.size.normal + font.weight: 600 + text: modelData.username + } + } + } + } + highlight: CustomRect { + color: DynamicColors.palette.m3primary + implicitHeight: users.currentItem?.implicitHeight ?? 0 + implicitWidth: users.width + radius: Appearance.rounding.normal - Appearance.padding.smaller + y: users.currentItem?.y ?? 0 + + Behavior on y { + Anim { + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.expressiveEffects + } + } + } + } + } +} diff --git a/Greeter/scripts/get-users b/Greeter/scripts/get-users new file mode 100755 index 0000000..c85aa08 --- /dev/null +++ b/Greeter/scripts/get-users @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Get users that are allowed to log in graphically. + +Criteria for a user to be considered a "graphical login" user: +1. UID >= 1000 (regular user, not a system account) +2. Has a valid login shell (not /sbin/nologin, /bin/false, etc.) +3. Has a home directory that exists + +Output: JSON array of user objects with username, uid, home, shell, and face (avatar path) +""" +import json +import os +import pwd + +# Shells that indicate a user cannot log in +INVALID_SHELLS = { + "/sbin/nologin", + "/usr/sbin/nologin", + "/usr/bin/nologin", + "/bin/false", + "/usr/bin/false", + "/bin/true", + "/usr/bin/true", + "", +} + +# Minimum UID for regular users (typically 1000 on most Linux distributions) +MIN_UID = 1000 + + +def get_face_path(home: str, username: str) -> str: + """Get the path to the user's face/avatar image if it exists.""" + # Check common locations for user avatars + candidates = [ + os.path.join(home, ".face"), + os.path.join(home, ".face.icon"), + f"/var/lib/AccountsService/icons/{username}", + ] + + for path in candidates: + if os.path.isfile(path): + return path + + return "" + + +def is_graphical_user(pw: pwd.struct_passwd) -> bool: + """Check if a user is allowed to log in graphically.""" + # Must be a regular user (UID >= 1000) + if pw.pw_uid < MIN_UID: + return False + + # Must have a valid login shell + if pw.pw_shell in INVALID_SHELLS: + return False + + # Home directory should exist + if not os.path.isdir(pw.pw_dir): + return False + + return True + + +def main(): + users = [] + + for pw in pwd.getpwall(): + if not is_graphical_user(pw): + continue + + users.append({ + "username": pw.pw_name, + "uid": pw.pw_uid, + "home": pw.pw_dir, + "shell": pw.pw_shell, + "gecos": pw.pw_gecos.split(",")[0] if pw.pw_gecos else "", # Full name from GECOS field + "face": get_face_path(pw.pw_dir, pw.pw_name), + }) + + # Sort by username for consistent ordering + users.sort(key=lambda u: u["username"]) + + print(json.dumps(users)) + + +if __name__ == "__main__": + main() diff --git a/Greeter/shell.qml b/Greeter/shell.qml index f0e481f..62c59bf 100644 --- a/Greeter/shell.qml +++ b/Greeter/shell.qml @@ -11,8 +11,6 @@ ShellRoot { GreeterState { id: greeter - - username: Config.general.username } GreeterSurface {