mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-07 19:59:14 -04:00
feat(Greeter): improved multi-user UI and per-user theme sync
- Introduce multi-account greeter login with per-user theme previews - Add `dms greeter sync --profile` for secondary users with or without sudo - Add Manage greeter group membership from Settings UI → Users Tab
This commit is contained in:
@@ -61,20 +61,27 @@ var greeterInstallCmd = &cobra.Command{
|
||||
var greeterSyncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync DMS theme and settings with greeter",
|
||||
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
|
||||
PreRunE: preRunPrivileged,
|
||||
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
profile, _ := cmd.Flags().GetBool("profile")
|
||||
if profile {
|
||||
return nil
|
||||
}
|
||||
return preRunPrivileged(cmd, args)
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
auth, _ := cmd.Flags().GetBool("auth")
|
||||
local, _ := cmd.Flags().GetBool("local")
|
||||
profile, _ := cmd.Flags().GetBool("profile")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
if term {
|
||||
if err := syncInTerminal(yes, auth, local); err != nil {
|
||||
if err := syncInTerminal(yes, auth, local, profile); err != nil {
|
||||
log.Fatalf("Error launching sync in terminal: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := syncGreeter(yes, auth, local); err != nil {
|
||||
if err := syncGreeter(yes, auth, local, profile); err != nil {
|
||||
log.Fatalf("Error syncing greeter: %v", err)
|
||||
}
|
||||
},
|
||||
@@ -85,6 +92,7 @@ func init() {
|
||||
greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done")
|
||||
greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles")
|
||||
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
|
||||
greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)")
|
||||
}
|
||||
|
||||
var greeterEnableCmd = &cobra.Command{
|
||||
@@ -512,8 +520,8 @@ func runCommandInTerminal(shellCmd string) error {
|
||||
return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)")
|
||||
}
|
||||
|
||||
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
syncFlags := make([]string, 0, 3)
|
||||
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error {
|
||||
syncFlags := make([]string, 0, 4)
|
||||
if nonInteractive {
|
||||
syncFlags = append(syncFlags, "--yes")
|
||||
}
|
||||
@@ -523,6 +531,9 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
if local {
|
||||
syncFlags = append(syncFlags, "--local")
|
||||
}
|
||||
if profileOnly {
|
||||
syncFlags = append(syncFlags, "--profile")
|
||||
}
|
||||
shellSyncCmd := "dms greeter sync"
|
||||
if len(syncFlags) > 0 {
|
||||
shellSyncCmd += " " + strings.Join(syncFlags, " ")
|
||||
@@ -541,7 +552,11 @@ func resolveLocalWrapperShell() (string, error) {
|
||||
return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper")
|
||||
}
|
||||
|
||||
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
func syncGreeter(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error {
|
||||
if profileOnly {
|
||||
return syncGreeterProfileOnly(nonInteractive)
|
||||
}
|
||||
|
||||
if !nonInteractive {
|
||||
fmt.Println("=== DMS Greeter Sync ===")
|
||||
fmt.Println()
|
||||
@@ -752,6 +767,26 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncGreeterProfileOnly(nonInteractive bool) error {
|
||||
logFunc := func(msg string) {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
if !nonInteractive {
|
||||
fmt.Println("=== DMS Greeter Profile Sync ===")
|
||||
fmt.Println()
|
||||
fmt.Println("Syncing your personal greeter theme slot (no system changes)...")
|
||||
}
|
||||
if err := greeter.SyncUserProfileCache(logFunc); err != nil {
|
||||
return err
|
||||
}
|
||||
if !nonInteractive {
|
||||
fmt.Println("\n=== Profile Sync Complete ===")
|
||||
fmt.Println("\nYour theme, wallpaper, and profile photo have been synced for the login screen.")
|
||||
fmt.Println("Log out to preview your greeter look when selecting your account.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasDmsShellQml(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, "shell.qml"))
|
||||
return err == nil && !info.IsDir()
|
||||
@@ -837,7 +872,14 @@ func resolveLocalDMSPath() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root or set DMS_LOCAL_PATH=/absolute/path/to/repo", wd)
|
||||
configuredCommand := readDefaultSessionCommand("/etc/greetd/config.toml")
|
||||
if pathOverride := extractGreeterPathOverrideFromCommand(configuredCommand); pathOverride != "" {
|
||||
if resolved, ok := resolveDMSLocalCandidate(pathOverride); ok {
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root, set DMS_LOCAL_PATH=/absolute/path/to/repo, or configure greetd with -p /path/to/quickshell", wd)
|
||||
}
|
||||
|
||||
func disableDisplayManager(dmName string) (bool, error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -572,6 +573,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
||||
}
|
||||
|
||||
runtimeDirs := []string{
|
||||
filepath.Join(cacheDir, "users"),
|
||||
filepath.Join(cacheDir, ".local"),
|
||||
filepath.Join(cacheDir, ".local", "state"),
|
||||
filepath.Join(cacheDir, ".local", "share"),
|
||||
@@ -1255,6 +1257,16 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
|
||||
return fmt.Errorf("greeter wallpaper override sync failed: %w", err)
|
||||
}
|
||||
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve syncing user for per-user greeter cache: %w", err)
|
||||
}
|
||||
if err := syncUserGreeterCacheSlot(homeDir, cacheDir, currentUser.Username, state, logFunc, userSlotSyncOpts{
|
||||
sudoPassword: sudoPassword,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("per-user greeter cache sync failed: %w", err)
|
||||
}
|
||||
|
||||
if strings.ToLower(compositor) != "niri" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
package greeter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
var monitorWallpaperSanitizer = regexp.MustCompile(`[^a-zA-Z0-9]+`)
|
||||
|
||||
func userGreeterCacheDir(cacheDir, username string) string {
|
||||
return filepath.Join(cacheDir, "users", username)
|
||||
}
|
||||
|
||||
func isUserOwnedGreeterCacheSlot(path, username string) bool {
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return false
|
||||
}
|
||||
userDir, err := filepath.Abs(userGreeterCacheDir(GreeterCacheDir, username))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return abs == userDir || strings.HasPrefix(abs, userDir+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func UserIsInGreeterGroup(username string) bool {
|
||||
group := DetectGreeterGroup()
|
||||
if !utils.HasGroup(group) {
|
||||
return false
|
||||
}
|
||||
groupsCmd := exec.Command("groups", username)
|
||||
groupsOutput, err := groupsCmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(groupsOutput), group)
|
||||
}
|
||||
|
||||
func CanSyncOwnUserGreeterProfile(username string) bool {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil || currentUser.Username != username {
|
||||
return false
|
||||
}
|
||||
if !UserIsInGreeterGroup(username) {
|
||||
return false
|
||||
}
|
||||
usersDir := filepath.Join(GreeterCacheDir, "users")
|
||||
if st, err := os.Stat(usersDir); err != nil || !st.IsDir() {
|
||||
return false
|
||||
}
|
||||
testFile := filepath.Join(usersDir, ".write-test-"+username)
|
||||
file, err := os.OpenFile(testFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = file.Close()
|
||||
_ = os.Remove(testFile)
|
||||
return true
|
||||
}
|
||||
|
||||
func GreeterProfileSyncReady() bool {
|
||||
if command := readGreeterSessionCommand(); command != "" && strings.Contains(command, "dms-greeter") {
|
||||
return true
|
||||
}
|
||||
usersDir := filepath.Join(GreeterCacheDir, "users")
|
||||
st, err := os.Stat(usersDir)
|
||||
return err == nil && st.IsDir()
|
||||
}
|
||||
|
||||
func readGreeterSessionCommand() string {
|
||||
data, err := os.ReadFile("/etc/greetd/config.toml")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
inDefaultSession := false
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
inDefaultSession = strings.EqualFold(strings.Trim(trimmed, "[]"), "default_session")
|
||||
continue
|
||||
}
|
||||
if !inDefaultSession {
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(trimmed, "#"); idx >= 0 {
|
||||
trimmed = strings.TrimSpace(trimmed[:idx])
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "command") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
command := strings.Trim(strings.TrimSpace(parts[1]), `"`)
|
||||
if command != "" {
|
||||
return command
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SyncUserProfileCache writes the current user's theme slot under users/<username>/
|
||||
// without modifying greetd or other system configuration. Requires membership in the
|
||||
// greeter group and a prior full greeter setup by an administrator.
|
||||
func SyncUserProfileCache(logFunc func(string)) error {
|
||||
if logFunc == nil {
|
||||
logFunc = func(string) {}
|
||||
}
|
||||
if !GreeterProfileSyncReady() {
|
||||
return fmt.Errorf("greeter is not set up on this system yet; an administrator must run 'dms greeter install' or 'dms greeter sync' once first")
|
||||
}
|
||||
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve current user: %w", err)
|
||||
}
|
||||
if !CanSyncOwnUserGreeterProfile(currentUser.Username) {
|
||||
group := DetectGreeterGroup()
|
||||
return fmt.Errorf("cannot sync greeter profile: you must be in the %s group with write access to %s/users\nAsk an administrator to run:\n sudo usermod -aG %s %s\nThen log out and back in before running:\n dms greeter sync --profile",
|
||||
group, GreeterCacheDir, group, currentUser.Username)
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
|
||||
state, err := resolveGreeterThemeSyncState(homeDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve greeter color source: %w", err)
|
||||
}
|
||||
|
||||
if err := syncUserGreeterCacheSlot(homeDir, GreeterCacheDir, currentUser.Username, state, logFunc, userSlotSyncOpts{
|
||||
profileOnly: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logFunc(fmt.Sprintf(" → %s/users/%s/", GreeterCacheDir, currentUser.Username))
|
||||
return nil
|
||||
}
|
||||
|
||||
func canWriteUserGreeterCacheSlot(dest, username string) bool {
|
||||
return isUserOwnedGreeterCacheSlot(dest, username) && CanSyncOwnUserGreeterProfile(username)
|
||||
}
|
||||
|
||||
type userSlotSyncOpts struct {
|
||||
sudoPassword string
|
||||
profileOnly bool
|
||||
username string
|
||||
}
|
||||
|
||||
func (o userSlotSyncOpts) useDirectWrite(dest string) bool {
|
||||
if !o.profileOnly {
|
||||
return false
|
||||
}
|
||||
return canWriteUserGreeterCacheSlot(dest, o.username)
|
||||
}
|
||||
|
||||
func isGreeterCachePath(path string) bool {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
cacheAbs, err := filepath.Abs(GreeterCacheDir)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if abs == cacheAbs {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(abs, cacheAbs+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func greeterCacheOwner() string {
|
||||
greeterGroup := DetectGreeterGroup()
|
||||
daemonUser := DetectGreeterUser()
|
||||
return daemonUser + ":" + greeterGroup
|
||||
}
|
||||
|
||||
func ensureGreeterCacheSubdir(dir string, opts userSlotSyncOpts) error {
|
||||
if opts.useDirectWrite(dir) {
|
||||
if err := os.MkdirAll(dir, 0o770); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory %s: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := privesc.Run(context.Background(), opts.sudoPassword, "mkdir", "-p", dir); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
owner := greeterCacheOwner()
|
||||
if err := privesc.Run(context.Background(), opts.sudoPassword, "chown", owner, dir); err != nil {
|
||||
if fallbackErr := privesc.Run(context.Background(), opts.sudoPassword, "chown", "root:"+DetectGreeterGroup(), dir); fallbackErr != nil {
|
||||
return fmt.Errorf("failed to set ownership on %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
if err := privesc.Run(context.Background(), opts.sudoPassword, "chmod", "2770", dir); err != nil {
|
||||
return fmt.Errorf("failed to set permissions on %s: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setGreeterCacheFileOwnership(path, sudoPassword string) error {
|
||||
owner := greeterCacheOwner()
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, path); err != nil {
|
||||
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", "root:"+DetectGreeterGroup(), path); fallbackErr != nil {
|
||||
return fmt.Errorf("failed to set ownership on %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", path); err != nil {
|
||||
return fmt.Errorf("failed to set permissions on %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncUserGreeterCacheSlot(homeDir, cacheDir, username string, state greeterThemeSyncState, logFunc func(string), opts userSlotSyncOpts) error {
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return nil
|
||||
}
|
||||
opts.username = username
|
||||
|
||||
userDir := userGreeterCacheDir(cacheDir, username)
|
||||
if err := ensureGreeterCacheSubdir(userDir, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
|
||||
settingsBytes, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read settings for user cache slot: %w", err)
|
||||
}
|
||||
|
||||
settingsMap := map[string]any{}
|
||||
if strings.TrimSpace(string(settingsBytes)) != "" {
|
||||
if err := json.Unmarshal(settingsBytes, &settingsMap); err != nil {
|
||||
return fmt.Errorf("failed to parse settings for user cache slot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if customTheme, ok := settingsMap["customThemeFile"].(string); ok && strings.TrimSpace(customTheme) != "" {
|
||||
resolvedTheme := customTheme
|
||||
if !filepath.IsAbs(resolvedTheme) {
|
||||
resolvedTheme = filepath.Join(homeDir, resolvedTheme)
|
||||
}
|
||||
if st, statErr := os.Stat(resolvedTheme); statErr == nil && !st.IsDir() {
|
||||
destTheme := filepath.Join(userDir, "custom-theme.json")
|
||||
if err := copyFileWithPrivesc(resolvedTheme, destTheme, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
settingsMap["customThemeFile"] = destTheme
|
||||
}
|
||||
}
|
||||
|
||||
settingsBytes, err = json.Marshal(settingsMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal settings for user cache slot: %w", err)
|
||||
}
|
||||
if err := writeFileWithPrivesc(filepath.Join(userDir, "settings.json"), settingsBytes, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessionPath := filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json")
|
||||
sessionBytes, err := os.ReadFile(sessionPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read session for user cache slot: %w", err)
|
||||
}
|
||||
|
||||
sessionMap := map[string]any{}
|
||||
if strings.TrimSpace(string(sessionBytes)) != "" {
|
||||
if err := json.Unmarshal(sessionBytes, &sessionMap); err != nil {
|
||||
return fmt.Errorf("failed to parse session for user cache slot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := localizeSessionWallpapers(sessionMap, userDir, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessionBytes, err = json.Marshal(sessionMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal session for user cache slot: %w", err)
|
||||
}
|
||||
if err := writeFileWithPrivesc(filepath.Join(userDir, "session.json"), sessionBytes, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
colorsSource := state.effectiveColorsSource(homeDir)
|
||||
if err := copyFileWithPrivesc(colorsSource, filepath.Join(userDir, "colors.json"), opts); err != nil {
|
||||
return fmt.Errorf("failed to copy colors for user cache slot: %w", err)
|
||||
}
|
||||
|
||||
if err := syncUserProfileImage(homeDir, userDir, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rootOverride := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
|
||||
userOverride := filepath.Join(userDir, "greeter_wallpaper_override.jpg")
|
||||
if st, statErr := os.Stat(rootOverride); statErr == nil && !st.IsDir() {
|
||||
if err := copyFileWithPrivesc(rootOverride, userOverride, opts); err != nil {
|
||||
return fmt.Errorf("failed to copy greeter wallpaper override for user cache slot: %w", err)
|
||||
}
|
||||
} else if opts.useDirectWrite(userOverride) {
|
||||
_ = os.Remove(userOverride)
|
||||
} else {
|
||||
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", userOverride)
|
||||
}
|
||||
|
||||
logFunc(fmt.Sprintf("✓ Synced per-user greeter cache for %s", username))
|
||||
return nil
|
||||
}
|
||||
|
||||
func localizeSessionWallpapers(session map[string]any, userDir string, opts userSlotSyncOpts) error {
|
||||
stringKeys := []struct {
|
||||
key string
|
||||
prefix string
|
||||
}{
|
||||
{"wallpaperPath", "wallpaper"},
|
||||
{"wallpaperPathLight", "wallpaper-light"},
|
||||
{"wallpaperPathDark", "wallpaper-dark"},
|
||||
}
|
||||
for _, item := range stringKeys {
|
||||
if err := localizeWallpaperStringField(session, item.key, userDir, item.prefix, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
mapKeys := []struct {
|
||||
key string
|
||||
prefix string
|
||||
}{
|
||||
{"monitorWallpapers", "wallpaper-monitor"},
|
||||
{"monitorWallpapersLight", "wallpaper-monitor-light"},
|
||||
{"monitorWallpapersDark", "wallpaper-monitor-dark"},
|
||||
}
|
||||
for _, item := range mapKeys {
|
||||
if err := localizeWallpaperMapField(session, item.key, userDir, item.prefix, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func localizeWallpaperStringField(session map[string]any, key, userDir, prefix string, opts userSlotSyncOpts) error {
|
||||
raw, ok := session[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
path, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
dest, err := copyWallpaperIntoUserCache(path, userDir, prefix, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dest != "" {
|
||||
session[key] = dest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func localizeWallpaperMapField(session map[string]any, key, userDir, prefix string, opts userSlotSyncOpts) error {
|
||||
raw, ok := session[key]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
values, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for monitor, rawPath := range values {
|
||||
path, ok := rawPath.(string)
|
||||
if !ok || strings.TrimSpace(path) == "" {
|
||||
continue
|
||||
}
|
||||
safeMonitor := monitorWallpaperSanitizer.ReplaceAllString(monitor, "-")
|
||||
dest, err := copyWallpaperIntoUserCache(path, userDir, prefix+"-"+safeMonitor, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dest != "" {
|
||||
values[monitor] = dest
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyWallpaperIntoUserCache(srcPath, userDir, prefix string, opts userSlotSyncOpts) (string, error) {
|
||||
if strings.TrimSpace(srcPath) == "" {
|
||||
return "", nil
|
||||
}
|
||||
st, err := os.Stat(srcPath)
|
||||
if err != nil || st.IsDir() {
|
||||
return "", nil
|
||||
}
|
||||
ext := filepath.Ext(srcPath)
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
dest := filepath.Join(userDir, prefix+ext)
|
||||
if err := copyFileWithPrivesc(srcPath, dest, opts); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func copyFileWithPrivesc(src, dest string, opts userSlotSyncOpts) error {
|
||||
if opts.useDirectWrite(dest) {
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o770); err != nil {
|
||||
return fmt.Errorf("failed to create parent dir for %s: %w", dest, err)
|
||||
}
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", src, err)
|
||||
}
|
||||
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", dest, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isGreeterCachePath(dest) {
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent dir for %s: %w", dest, err)
|
||||
}
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", src, err)
|
||||
}
|
||||
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", dest, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", dest)
|
||||
if err := privesc.Run(context.Background(), opts.sudoPassword, "cp", src, dest); err != nil {
|
||||
return fmt.Errorf("failed to copy %s to %s: %w", src, dest, err)
|
||||
}
|
||||
return setGreeterCacheFileOwnership(dest, opts.sudoPassword)
|
||||
}
|
||||
|
||||
func writeFileWithPrivesc(path string, data []byte, opts userSlotSyncOpts) error {
|
||||
if opts.useDirectWrite(path) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o770); err != nil {
|
||||
return fmt.Errorf("failed to create parent dir for %s: %w", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isGreeterCachePath(path) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent dir for %s: %w", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "dms-greeter-user-cache-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file for %s: %w", path, err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to write temp file for %s: %w", path, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to close temp file for %s: %w", path, err)
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", path)
|
||||
if err := privesc.Run(context.Background(), opts.sudoPassword, "cp", tmpPath, path); err != nil {
|
||||
return fmt.Errorf("failed to install %s: %w", path, err)
|
||||
}
|
||||
return setGreeterCacheFileOwnership(path, opts.sudoPassword)
|
||||
}
|
||||
|
||||
func resolveUserProfileImageSource(homeDir string) string {
|
||||
candidates := []string{
|
||||
filepath.Join(homeDir, ".face"),
|
||||
filepath.Join(homeDir, ".face.icon"),
|
||||
}
|
||||
if homeDir != "" {
|
||||
username := filepath.Base(homeDir)
|
||||
if username != "" && username != "." && username != string(filepath.Separator) {
|
||||
candidates = append([]string{filepath.Join("/var/lib/AccountsService/icons", username)}, candidates...)
|
||||
}
|
||||
}
|
||||
for _, src := range candidates {
|
||||
st, err := os.Stat(src)
|
||||
if err == nil && !st.IsDir() && st.Size() > 0 {
|
||||
return src
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func syncUserProfileImage(homeDir, userDir string, opts userSlotSyncOpts) error {
|
||||
for _, name := range []string{"profile.jpg", "profile.jpeg", "profile.png", "profile.webp"} {
|
||||
path := filepath.Join(userDir, name)
|
||||
if opts.useDirectWrite(path) {
|
||||
_ = os.Remove(path)
|
||||
} else {
|
||||
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", path)
|
||||
}
|
||||
}
|
||||
|
||||
src := resolveUserProfileImageSource(homeDir)
|
||||
if src == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := filepath.Ext(src)
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
dest := filepath.Join(userDir, "profile"+ext)
|
||||
if err := copyFileWithPrivesc(src, dest, opts); err != nil {
|
||||
return fmt.Errorf("failed to copy profile image for user cache slot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package greeter
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserGreeterCacheDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := userGreeterCacheDir("/var/cache/dms-greeter", "alice")
|
||||
want := filepath.Join("/var/cache/dms-greeter", "users", "alice")
|
||||
if got != want {
|
||||
t.Fatalf("userGreeterCacheDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserProfileImageSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
homeDir := t.TempDir()
|
||||
facePath := filepath.Join(homeDir, ".face")
|
||||
writeTestFile(t, facePath, "face")
|
||||
|
||||
got := resolveUserProfileImageSource(homeDir)
|
||||
if got != facePath {
|
||||
t.Fatalf("resolveUserProfileImageSource() = %q, want %q", got, facePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUserOwnedGreeterCacheSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
slot := filepath.Join(GreeterCacheDir, "users", "alice", "settings.json")
|
||||
if !isUserOwnedGreeterCacheSlot(slot, "alice") {
|
||||
t.Fatalf("expected alice to own %q", slot)
|
||||
}
|
||||
if isUserOwnedGreeterCacheSlot(slot, "bob") {
|
||||
t.Fatalf("expected bob not to own alice slot")
|
||||
}
|
||||
if isUserOwnedGreeterCacheSlot(filepath.Join(GreeterCacheDir, "settings.json"), "alice") {
|
||||
t.Fatalf("expected root cache file not to be a user slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizeSessionWallpapers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
homeDir := t.TempDir()
|
||||
userDir := filepath.Join(homeDir, "users", "alice")
|
||||
wallpaperPath := filepath.Join(homeDir, "wall.jpg")
|
||||
writeTestFile(t, wallpaperPath, "wallpaper")
|
||||
|
||||
session := map[string]any{
|
||||
"wallpaperPath": wallpaperPath,
|
||||
"monitorWallpapers": map[string]any{
|
||||
"DP-1": wallpaperPath,
|
||||
},
|
||||
}
|
||||
|
||||
if err := localizeSessionWallpapers(session, userDir, userSlotSyncOpts{}); err != nil {
|
||||
t.Fatalf("localizeSessionWallpapers returned error: %v", err)
|
||||
}
|
||||
|
||||
gotPath, ok := session["wallpaperPath"].(string)
|
||||
if !ok || gotPath == "" {
|
||||
t.Fatalf("expected localized wallpaperPath, got %#v", session["wallpaperPath"])
|
||||
}
|
||||
if gotPath == wallpaperPath {
|
||||
t.Fatalf("expected copied wallpaper path, still points to source")
|
||||
}
|
||||
|
||||
monitorMap, ok := session["monitorWallpapers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected monitorWallpapers map")
|
||||
}
|
||||
monitorPath, ok := monitorMap["DP-1"].(string)
|
||||
if !ok || monitorPath == "" || monitorPath == wallpaperPath {
|
||||
t.Fatalf("expected localized monitor wallpaper, got %#v", monitorMap["DP-1"])
|
||||
}
|
||||
}
|
||||
@@ -1353,13 +1353,27 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
||||
|
||||
property string greeterSessionBaseDir: root._greeterCacheDir
|
||||
|
||||
function setGreeterSessionBaseDir(dir) {
|
||||
const next = dir || root._greeterCacheDir;
|
||||
if (greeterSessionBaseDir === next)
|
||||
return;
|
||||
greeterSessionBaseDir = next;
|
||||
if (isGreeterMode)
|
||||
greeterSessionFile.reload();
|
||||
}
|
||||
|
||||
function resetGreeterSessionBaseDir() {
|
||||
setGreeterSessionBaseDir(root._greeterCacheDir);
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: greeterSessionFile
|
||||
|
||||
path: {
|
||||
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
|
||||
return greetCfgDir + "/session.json";
|
||||
}
|
||||
path: root.greeterSessionBaseDir ? (root.greeterSessionBaseDir + "/session.json") : ""
|
||||
preload: isGreeterMode
|
||||
blockLoading: false
|
||||
blockWrites: true
|
||||
|
||||
@@ -2079,12 +2079,29 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
||||
|
||||
property string greeterColorsBaseDir: root._greeterCacheDir
|
||||
|
||||
function setGreeterColorsBaseDir(dir) {
|
||||
const next = dir || root._greeterCacheDir;
|
||||
if (greeterColorsBaseDir === next)
|
||||
return;
|
||||
greeterColorsBaseDir = next;
|
||||
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode)
|
||||
dynamicColorsFileView.reload();
|
||||
}
|
||||
|
||||
function resetGreeterColorsBaseDir() {
|
||||
setGreeterColorsBaseDir(root._greeterCacheDir);
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: dynamicColorsFileView
|
||||
path: {
|
||||
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
|
||||
const colorsPath = SessionData.isGreeterMode ? greetCfgDir + "/colors.json" : stateDir + "/dms-colors.json";
|
||||
return colorsPath;
|
||||
if (SessionData.isGreeterMode)
|
||||
return root.greeterColorsBaseDir ? (root.greeterColorsBaseDir + "/colors.json") : "";
|
||||
return stateDir + "/dms-colors.json";
|
||||
}
|
||||
blockLoading: false
|
||||
watchChanges: !SessionData.isGreeterMode
|
||||
|
||||
@@ -12,16 +12,24 @@ Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("GreetdSettings")
|
||||
|
||||
readonly property string configPath: {
|
||||
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
|
||||
return greetCfgDir + "/settings.json";
|
||||
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
||||
|
||||
property string configBaseDir: root._greeterCacheDir
|
||||
readonly property string configPath: root.configBaseDir ? (root.configBaseDir + "/settings.json") : ""
|
||||
readonly property string greeterWallpaperOverridePath: root.configBaseDir ? (root.configBaseDir + "/greeter_wallpaper_override.jpg") : ""
|
||||
|
||||
function setConfigBaseDir(dir) {
|
||||
const next = dir || root._greeterCacheDir;
|
||||
if (configBaseDir === next)
|
||||
return;
|
||||
configBaseDir = next;
|
||||
settingsLoaded = false;
|
||||
settingsFile.reload();
|
||||
}
|
||||
|
||||
readonly property string _greeterCacheDir: {
|
||||
const i = root.configPath.lastIndexOf("/");
|
||||
return i >= 0 ? root.configPath.substring(0, i) : "";
|
||||
function resetConfigBaseDir() {
|
||||
setConfigBaseDir(root._greeterCacheDir);
|
||||
}
|
||||
readonly property string greeterWallpaperOverridePath: root._greeterCacheDir ? (root._greeterCacheDir + "/greeter_wallpaper_override.jpg") : ""
|
||||
|
||||
property string currentThemeName: "purple"
|
||||
property bool settingsLoaded: false
|
||||
|
||||
@@ -62,6 +62,11 @@ Item {
|
||||
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
||||
readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f)
|
||||
readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f
|
||||
readonly property bool multipleUsersAvailable: GreeterUsersService.loaded && GreeterUsersService.users.length > 1
|
||||
readonly property bool showUserPicker: multipleUsersAvailable && !GreeterState.showPasswordInput
|
||||
property bool userListOpen: false
|
||||
property bool skipAutoSelectUser: false
|
||||
property string pickerThemeUsername: ""
|
||||
|
||||
function initWeatherService() {
|
||||
if (weatherInitialized)
|
||||
@@ -428,20 +433,61 @@ Item {
|
||||
fprintdDeviceProbe.running = true;
|
||||
}
|
||||
|
||||
function applyPickerPreviewTheme() {
|
||||
let previewUser = (pickerThemeUsername || "").trim();
|
||||
if (!previewUser && GreetdSettings.rememberLastUser)
|
||||
previewUser = (GreetdMemory.lastSuccessfulUser || "").trim();
|
||||
if (previewUser)
|
||||
GreeterUserTheme.applyForUser(previewUser);
|
||||
else
|
||||
GreeterUserTheme.applyDefault();
|
||||
}
|
||||
|
||||
function applyLastSuccessfulUser() {
|
||||
if (root.skipAutoSelectUser)
|
||||
return;
|
||||
if (!GreetdSettings.settingsLoaded || !GreetdSettings.rememberLastUser)
|
||||
return;
|
||||
const lastUser = GreetdMemory.lastSuccessfulUser;
|
||||
if (lastUser && !GreeterState.showPasswordInput && !GreeterState.username) {
|
||||
GreeterState.username = lastUser;
|
||||
GreeterState.usernameInput = lastUser;
|
||||
GreeterState.showPasswordInput = true;
|
||||
PortalService.getGreeterUserProfileImage(lastUser);
|
||||
maybeAutoStartExternalAuth();
|
||||
selectUser(lastUser, true);
|
||||
}
|
||||
}
|
||||
|
||||
function submitUsername(rawValue) {
|
||||
function returnToUserPicker() {
|
||||
if (!root.multipleUsersAvailable || GreeterState.unlocking)
|
||||
return;
|
||||
root.skipAutoSelectUser = true;
|
||||
awaitingExternalAuth = false;
|
||||
pendingPasswordResponse = false;
|
||||
passwordSubmitRequested = false;
|
||||
resetPasswordSessionTransition(true);
|
||||
authTimeout.interval = defaultAuthTimeoutMs;
|
||||
authTimeout.stop();
|
||||
clearAuthFeedback();
|
||||
passwordFailureCount = 0;
|
||||
externalAuthAutoStartedForUser = "";
|
||||
if (Greetd.state !== GreetdState.Inactive)
|
||||
Greetd.cancelSession();
|
||||
const previousUser = GreeterState.username;
|
||||
GreeterState.reset();
|
||||
inputField.text = "";
|
||||
PortalService.profileImage = "";
|
||||
if (previousUser)
|
||||
root.pickerThemeUsername = previousUser;
|
||||
root.applyPickerPreviewTheme();
|
||||
root.userListOpen = true;
|
||||
}
|
||||
|
||||
function selectUser(rawValue, skipDropdownUpdate) {
|
||||
const user = (rawValue || "").trim();
|
||||
if (!user)
|
||||
return;
|
||||
root.skipAutoSelectUser = false;
|
||||
submitUsername(user, skipDropdownUpdate === true);
|
||||
}
|
||||
|
||||
function submitUsername(rawValue, skipDropdownUpdate) {
|
||||
const user = (rawValue || "").trim();
|
||||
if (!user)
|
||||
return;
|
||||
@@ -450,8 +496,15 @@ Item {
|
||||
clearAuthFeedback();
|
||||
externalAuthAutoStartedForUser = "";
|
||||
}
|
||||
root.pickerThemeUsername = user;
|
||||
GreeterState.username = user;
|
||||
GreeterState.usernameInput = user;
|
||||
GreeterState.showPasswordInput = true;
|
||||
if (!skipDropdownUpdate && typeof GreeterUsersService !== "undefined") {
|
||||
const idx = GreeterUsersService.usernames.indexOf(user);
|
||||
GreeterState.selectedUserIndex = idx;
|
||||
}
|
||||
root.userListOpen = false;
|
||||
PortalService.getGreeterUserProfileImage(user);
|
||||
GreeterState.passwordBuffer = "";
|
||||
pendingPasswordResponse = false;
|
||||
@@ -637,13 +690,44 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: GreeterUsersService
|
||||
function onLoadedChanged() {
|
||||
if (GreeterUsersService.loaded && isPrimaryScreen)
|
||||
applyPickerPreviewTheme();
|
||||
}
|
||||
function onSyncedThemePathsChanged() {
|
||||
if (!isPrimaryScreen)
|
||||
return;
|
||||
if (GreeterState.username)
|
||||
GreeterUserTheme.applyForUser(GreeterState.username);
|
||||
else if (root.showUserPicker || root.userListOpen)
|
||||
applyPickerPreviewTheme();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: GreeterState
|
||||
function onUsernameChanged() {
|
||||
if (GreeterState.username) {
|
||||
root.pickerThemeUsername = GreeterState.username;
|
||||
GreeterUserTheme.applyForUser(GreeterState.username);
|
||||
PortalService.getGreeterUserProfileImage(GreeterState.username);
|
||||
} else if (root.showUserPicker || root.userListOpen) {
|
||||
applyPickerPreviewTheme();
|
||||
}
|
||||
}
|
||||
function onShowPasswordInputChanged() {
|
||||
if (GreeterState.showPasswordInput)
|
||||
root.userListOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShowUserPickerChanged: {
|
||||
if (showUserPicker && !GreeterState.username)
|
||||
applyPickerPreviewTheme();
|
||||
if (!showUserPicker)
|
||||
userListOpen = false;
|
||||
}
|
||||
|
||||
FileView {
|
||||
@@ -736,16 +820,23 @@ Item {
|
||||
anchors.fill: parent
|
||||
color: "transparent"
|
||||
|
||||
Column {
|
||||
id: greeterMainColumn
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingM
|
||||
width: 380
|
||||
|
||||
Item {
|
||||
id: clockContainer
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.verticalCenter
|
||||
anchors.bottomMargin: 60
|
||||
|
||||
width: parent.width
|
||||
height: clockText.implicitHeight
|
||||
|
||||
Row {
|
||||
id: clockText
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
spacing: 0
|
||||
@@ -857,36 +948,49 @@ Item {
|
||||
|
||||
StyledText {
|
||||
id: dateText
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: clockContainer.bottom
|
||||
anchors.topMargin: 4
|
||||
text: {
|
||||
return systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat());
|
||||
}
|
||||
text: systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat())
|
||||
font.pixelSize: Theme.fontSizeXLarge
|
||||
color: "white"
|
||||
opacity: 0.9
|
||||
}
|
||||
|
||||
Item {
|
||||
StyledText {
|
||||
id: userPickerHint
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: dateText.bottom
|
||||
anchors.topMargin: Theme.spacingL
|
||||
width: 380
|
||||
height: 140
|
||||
visible: root.showUserPicker && !GreeterState.showPasswordInput && !GreeterState.username && !root.userListOpen
|
||||
text: I18n.tr("Select user...", "greeter user picker placeholder")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: "white"
|
||||
opacity: 0.85
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
id: authColumn
|
||||
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.spacingL
|
||||
Layout.fillWidth: true
|
||||
|
||||
DankCircularImage {
|
||||
Item {
|
||||
Layout.preferredWidth: 60
|
||||
Layout.preferredHeight: 60
|
||||
visible: GreetdSettings.lockScreenShowProfileImage || root.multipleUsersAvailable
|
||||
|
||||
DankCircularImage {
|
||||
anchors.fill: parent
|
||||
imageSource: {
|
||||
const displayUser = GreeterState.username || root.pickerThemeUsername;
|
||||
if (displayUser) {
|
||||
const cachedPath = GreeterUsersService.profileImagePath(displayUser);
|
||||
if (cachedPath)
|
||||
return encodeFileUrl(cachedPath);
|
||||
}
|
||||
if (PortalService.profileImage === "")
|
||||
return "";
|
||||
if (PortalService.profileImage.startsWith("/"))
|
||||
@@ -894,19 +998,64 @@ Item {
|
||||
return PortalService.profileImage;
|
||||
}
|
||||
fallbackIcon: "person"
|
||||
visible: GreetdSettings.lockScreenShowProfileImage
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
border.color: Theme.primary
|
||||
border.width: avatarPickerArea.containsMouse || root.userListOpen ? 2 : 0
|
||||
visible: root.multipleUsersAvailable
|
||||
Behavior on border.width {
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: avatarPickerArea
|
||||
|
||||
anchors.fill: parent
|
||||
visible: root.multipleUsersAvailable
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (GreeterState.showPasswordInput)
|
||||
root.returnToUserPicker();
|
||||
else
|
||||
root.userListOpen = !root.userListOpen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
property bool showPassword: false
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 60
|
||||
Layout.preferredHeight: root.showUserPicker && root.userListOpen ? Math.max(60, userPicker.implicitHeight + Theme.spacingM * 2) : 60
|
||||
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.9)
|
||||
border.color: inputField.activeFocus ? Theme.primary : Qt.rgba(1, 1, 1, 0.3)
|
||||
border.width: inputField.activeFocus ? 2 : 1
|
||||
|
||||
GreeterUserPicker {
|
||||
id: userPicker
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: root.userListOpen ? undefined : parent.verticalCenter
|
||||
anchors.top: root.userListOpen ? parent.top : undefined
|
||||
anchors.margins: Theme.spacingM
|
||||
visible: root.showUserPicker && !GreeterState.showPasswordInput
|
||||
expanded: root.userListOpen
|
||||
onUserSelected: username => root.selectUser(username, false)
|
||||
onToggleRequested: root.userListOpen = !root.userListOpen
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
id: lockIcon
|
||||
|
||||
@@ -916,6 +1065,7 @@ Item {
|
||||
name: GreeterState.showPasswordInput ? "lock" : "person"
|
||||
size: 20
|
||||
color: inputField.activeFocus ? Theme.primary : Theme.surfaceVariantText
|
||||
visible: !root.showUserPicker
|
||||
}
|
||||
|
||||
TextInput {
|
||||
@@ -941,8 +1091,9 @@ Item {
|
||||
}
|
||||
return margin;
|
||||
}
|
||||
enabled: !root.showUserPicker || GreeterState.showPasswordInput
|
||||
opacity: 0
|
||||
focus: true
|
||||
focus: !root.showUserPicker || GreeterState.showPasswordInput
|
||||
echoMode: GreeterState.showPasswordInput ? (parent.showPassword ? TextInput.Normal : TextInput.Password) : TextInput.Normal
|
||||
onTextChanged: {
|
||||
if (syncingFromState)
|
||||
@@ -1005,11 +1156,14 @@ Item {
|
||||
if (GreeterState.showPasswordInput) {
|
||||
return I18n.tr("Password...");
|
||||
}
|
||||
if (root.showUserPicker) {
|
||||
return "";
|
||||
}
|
||||
return I18n.tr("Username...");
|
||||
}
|
||||
color: (GreeterState.unlocking || (Greetd.state !== GreetdState.Inactive && !awaitingExternalAuth && !pendingPasswordResponse)) ? Theme.primary : Theme.outline
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length === 0 : GreeterState.usernameInput.length === 0) ? 1 : 0
|
||||
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length === 0 : (root.showUserPicker ? false : GreeterState.usernameInput.length === 0)) ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
@@ -1043,7 +1197,7 @@ Item {
|
||||
}
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: (GreeterState.showPasswordInput && !parent.showPassword) ? Theme.fontSizeLarge : Theme.fontSizeMedium
|
||||
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length > 0 : GreeterState.usernameInput.length > 0) ? 1 : 0
|
||||
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length > 0 : (root.showUserPicker ? false : GreeterState.usernameInput.length > 0)) ? 1 : 0
|
||||
clip: true
|
||||
elide: Text.ElideNone
|
||||
horizontalAlignment: implicitWidth > width ? Text.AlignRight : Text.AlignLeft
|
||||
@@ -1088,7 +1242,7 @@ Item {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "keyboard"
|
||||
buttonSize: 32
|
||||
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking
|
||||
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking && (!root.showUserPicker || GreeterState.showPasswordInput)
|
||||
enabled: visible
|
||||
onClicked: {
|
||||
if (keyboard_controller.isKeyboardActive) {
|
||||
@@ -1107,7 +1261,7 @@ Item {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "keyboard_return"
|
||||
buttonSize: 36
|
||||
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking
|
||||
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking && (!root.showUserPicker || GreeterState.showPasswordInput)
|
||||
enabled: true
|
||||
onClicked: {
|
||||
if (GreeterState.showPasswordInput) {
|
||||
@@ -1198,13 +1352,8 @@ Item {
|
||||
StateLayer {
|
||||
stateColor: Theme.primary
|
||||
cornerRadius: parent.radius
|
||||
enabled: !GreeterState.unlocking && Greetd.state === GreetdState.Inactive && GreeterState.showPasswordInput
|
||||
onClicked: {
|
||||
GreeterState.reset();
|
||||
root.externalAuthAutoStartedForUser = "";
|
||||
inputField.text = "";
|
||||
PortalService.profileImage = "";
|
||||
}
|
||||
enabled: !GreeterState.unlocking && GreeterState.showPasswordInput
|
||||
onClicked: root.returnToUserPicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ Singleton {
|
||||
property var sessionExecs: []
|
||||
property var sessionPaths: []
|
||||
property int currentSessionIndex: 0
|
||||
property var availableUsers: []
|
||||
property int selectedUserIndex: -1
|
||||
|
||||
function reset() {
|
||||
showPasswordInput = false;
|
||||
@@ -26,5 +28,6 @@ Singleton {
|
||||
usernameInput = "";
|
||||
passwordBuffer = "";
|
||||
pamState = "";
|
||||
selectedUserIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool expanded: false
|
||||
|
||||
signal userSelected(string username)
|
||||
signal toggleRequested()
|
||||
|
||||
function encodeFileUrl(path) {
|
||||
if (!path)
|
||||
return "";
|
||||
return "file://" + path.split("/").map(s => encodeURIComponent(s)).join("/");
|
||||
}
|
||||
|
||||
function profileImageSource(username) {
|
||||
const path = GreeterUsersService.profileImagePath(username);
|
||||
if (path)
|
||||
return encodeFileUrl(path);
|
||||
return "";
|
||||
}
|
||||
|
||||
implicitHeight: column.implicitHeight
|
||||
implicitWidth: parent ? parent.width : 320
|
||||
|
||||
ColumnLayout {
|
||||
id: column
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: Theme.spacingS
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacingM
|
||||
visible: !root.expanded && !!GreeterState.username
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: GreeterUsersService.optionLabel(GreeterState.username)
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
name: "expand_more"
|
||||
size: 20
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 36
|
||||
visible: !root.expanded && !GreeterState.username
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "expand_more"
|
||||
size: 20
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleRequested()
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacingXS
|
||||
visible: root.expanded
|
||||
|
||||
Repeater {
|
||||
model: GreeterUsersService.users
|
||||
|
||||
delegate: Rectangle {
|
||||
id: userRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 52
|
||||
radius: Theme.cornerRadius
|
||||
color: userRowMouse.containsMouse ? Theme.surfacePressed : "transparent"
|
||||
border.color: GreeterState.username === userRow.modelData.username ? Theme.primary : "transparent"
|
||||
border.width: GreeterState.username === userRow.modelData.username ? 1 : 0
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: 36
|
||||
Layout.preferredHeight: 36
|
||||
|
||||
DankCircularImage {
|
||||
anchors.fill: parent
|
||||
imageSource: root.profileImageSource(userRow.modelData.username)
|
||||
fallbackIcon: "person"
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: GreeterUsersService.optionLabel(userRow.modelData.username)
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: userRowMouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.userSelected(userRow.modelData.username)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var log: Log.scoped("GreeterUserTheme")
|
||||
readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
||||
|
||||
property string activeUsername: ""
|
||||
|
||||
function userCacheDir(username) {
|
||||
if (!username)
|
||||
return "";
|
||||
return greetCfgDir + "/users/" + username;
|
||||
}
|
||||
|
||||
function applyForUser(username) {
|
||||
const name = (username || "").trim();
|
||||
activeUsername = name;
|
||||
if (!name) {
|
||||
applyDefault();
|
||||
return;
|
||||
}
|
||||
const dir = userCacheDir(name);
|
||||
if (typeof GreeterUsersService !== "undefined" && GreeterUsersService.hasSyncedTheme(name)) {
|
||||
Theme.setGreeterColorsBaseDir(dir);
|
||||
SessionData.setGreeterSessionBaseDir(dir);
|
||||
GreetdSettings.setConfigBaseDir(dir);
|
||||
return;
|
||||
}
|
||||
applyDefault();
|
||||
}
|
||||
|
||||
function applyDefault() {
|
||||
activeUsername = "";
|
||||
Theme.resetGreeterColorsBaseDir();
|
||||
SessionData.resetGreeterSessionBaseDir();
|
||||
GreetdSettings.resetConfigBaseDir();
|
||||
}
|
||||
|
||||
readonly property string activeWallpaperOverridePath: {
|
||||
const base = activeUsername && typeof GreeterUsersService !== "undefined" && GreeterUsersService.hasSyncedTheme(activeUsername) ? userCacheDir(activeUsername) : greetCfgDir;
|
||||
return base ? base + "/greeter_wallpaper_override.jpg" : "";
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,17 @@ Only niri currently has a generated greeter config path managed by `dms greeter
|
||||
|
||||
The greeter can be personalized with wallpapers, themes, weather, clock formats, and more - configured exactly the same as dms.
|
||||
|
||||
**Easiest method:** Run `dms greeter sync` to automatically sync your DMS theme with the greeter.
|
||||
**Easiest method (single user):** Run `dms greeter sync` to automatically sync your DMS theme with the greeter.
|
||||
|
||||
**Multi-user systems:** One **main admin** runs full sync once to set up greetd and the shared cache (`dms greeter sync`, or `dms greeter sync --local` when developing from a checkout). **Every other account**—including other admins—should only run:
|
||||
|
||||
```bash
|
||||
dms greeter sync --profile
|
||||
```
|
||||
|
||||
Before that, an administrator must add each user to the `greeter` group in **Settings → Users** (greeter toggle) or with `sudo usermod -aG greeter <username>`. Each added user must log out and back in before `--profile` will work.
|
||||
|
||||
Per-user settings are stored under `/var/cache/dms-greeter/users/<username>/` for the login picker; the root cache remains the default fallback and is owned by whoever ran full sync.
|
||||
|
||||
**Manual method:** You can manually synchronize configurations if you want greeter settings to always mirror your shell:
|
||||
|
||||
|
||||
@@ -446,7 +446,7 @@ Item {
|
||||
settingKey: "greeterStatus"
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Check sync status on demand. Sync copies your theme, settings, and wallpaper configuration to the login screen. Authentication changes apply automatically.")
|
||||
text: I18n.tr("Check sync status on demand. Sync (full) is for the main admin: it copies your theme to the login screen and sets up system greeter config. On multi-user systems, add other accounts in Settings → Users, then have each of them run dms greeter sync --profile after logging out and back in—not full sync. Authentication changes apply automatically.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
|
||||
@@ -17,12 +17,14 @@ Item {
|
||||
property string pendingPassword: ""
|
||||
property string pendingConfirm: ""
|
||||
property bool pendingAdmin: false
|
||||
property bool pendingGreeter: false
|
||||
|
||||
function _resetForm() {
|
||||
pendingUsername = "";
|
||||
pendingPassword = "";
|
||||
pendingConfirm = "";
|
||||
pendingAdmin = false;
|
||||
pendingGreeter = false;
|
||||
usernameField.text = "";
|
||||
passwordField.text = "";
|
||||
confirmField.text = "";
|
||||
@@ -59,6 +61,10 @@ Item {
|
||||
id: adminToggleConfirm
|
||||
}
|
||||
|
||||
ConfirmModal {
|
||||
id: greeterToggleConfirm
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
@@ -112,6 +118,26 @@ Item {
|
||||
height: 1
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Greeter group:")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: UsersService.greeterGroup
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Item {
|
||||
width: Theme.spacingM
|
||||
height: 1
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: UsersService.refreshing ? I18n.tr("Refreshing…") : ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
@@ -120,6 +146,14 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Greeter group members can sync their login-screen theme with dms greeter sync --profile after logging out and back in.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: UsersService.users
|
||||
|
||||
@@ -179,6 +213,24 @@ Item {
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: userRow.modelData.isGreeter
|
||||
width: greeterChipText.implicitWidth + Theme.spacingS * 2
|
||||
height: greeterChipText.implicitHeight + Theme.spacingXS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.secondary, 0.15)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
StyledText {
|
||||
id: greeterChipText
|
||||
anchors.centerIn: parent
|
||||
text: I18n.tr("greeter")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.secondary
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
@@ -195,6 +247,34 @@ Item {
|
||||
spacing: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
DankActionButton {
|
||||
id: greeterToggleBtn
|
||||
readonly property bool actionBlocked: root.operationPending
|
||||
buttonSize: 36
|
||||
iconSize: 20
|
||||
iconName: userRow.modelData.isGreeter ? "login" : "how_to_reg"
|
||||
iconColor: userRow.modelData.isGreeter ? Theme.secondary : Theme.surfaceVariantText
|
||||
opacity: actionBlocked ? 0.4 : 1.0
|
||||
tooltipText: userRow.modelData.isGreeter ? I18n.tr("Remove greeter login access") : I18n.tr("Allow greeter login access")
|
||||
tooltipSide: "left"
|
||||
onClicked: {
|
||||
if (actionBlocked)
|
||||
return;
|
||||
const enableGreeter = !userRow.modelData.isGreeter;
|
||||
greeterToggleConfirm.showWithOptions({
|
||||
title: enableGreeter ? I18n.tr("Allow greeter access?") : I18n.tr("Remove greeter access?"),
|
||||
message: enableGreeter ? I18n.tr("Add \"%1\" to the %2 group? They must log out and back in, then run dms greeter sync --profile to publish their login-screen theme.").arg(userRow.modelData.username).arg(UsersService.greeterGroup) : I18n.tr("Remove \"%1\" from the %2 group?").arg(userRow.modelData.username).arg(UsersService.greeterGroup),
|
||||
confirmText: enableGreeter ? I18n.tr("Allow") : I18n.tr("Remove"),
|
||||
confirmColor: Theme.primary,
|
||||
onConfirm: () => {
|
||||
root.operationPending = true;
|
||||
root.statusText = "";
|
||||
UsersService.setGreeterAccess(userRow.modelData.username, enableGreeter, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: adminToggleBtn
|
||||
readonly property bool actionBlocked: root.operationPending || (userRow.isLastAdmin && userRow.modelData.isAdmin)
|
||||
@@ -380,6 +460,15 @@ Item {
|
||||
onToggled: checked => root.pendingAdmin = checked
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "createUserGreeter"
|
||||
tags: ["user", "greeter", "login", "sync"]
|
||||
text: I18n.tr("Allow greeter login access")
|
||||
description: I18n.tr("Add the new user to the %1 group so they can run dms greeter sync --profile.").arg(UsersService.greeterGroup)
|
||||
checked: root.pendingGreeter
|
||||
onToggled: checked => root.pendingGreeter = checked
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
@@ -395,7 +484,7 @@ Item {
|
||||
return;
|
||||
root.operationPending = true;
|
||||
root.statusText = "";
|
||||
UsersService.createUser(root.pendingUsername, root.pendingPassword, root.pendingAdmin, null);
|
||||
UsersService.createUser(root.pendingUsername, root.pendingPassword, root.pendingAdmin, root.pendingGreeter, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var log: Log.scoped("GreeterUsersService")
|
||||
|
||||
readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
||||
readonly property string usersCacheDir: greetCfgDir + "/users"
|
||||
|
||||
property var users: []
|
||||
property var usernames: []
|
||||
property var profileImageMap: ({})
|
||||
property bool loaded: false
|
||||
property bool refreshing: false
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
function refresh() {
|
||||
if (refreshing)
|
||||
return;
|
||||
refreshing = true;
|
||||
_loadUsers();
|
||||
}
|
||||
|
||||
function displayName(username) {
|
||||
const u = _findUser(username);
|
||||
if (!u)
|
||||
return username || "";
|
||||
const gecos = (u.gecos || "").trim();
|
||||
return gecos.length > 0 ? gecos : username;
|
||||
}
|
||||
|
||||
function optionLabel(username) {
|
||||
const label = displayName(username);
|
||||
return label !== username ? label : username;
|
||||
}
|
||||
|
||||
function usernameFromOptionLabel(label) {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
if (root.optionLabel(users[i].username) === label)
|
||||
return users[i].username;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function hasSyncedTheme(username) {
|
||||
if (!username)
|
||||
return false;
|
||||
return syncedThemePaths[username] === true;
|
||||
}
|
||||
|
||||
property var syncedThemePaths: ({})
|
||||
|
||||
function userCacheDir(username) {
|
||||
if (!username)
|
||||
return "";
|
||||
return usersCacheDir + "/" + username;
|
||||
}
|
||||
|
||||
function syncedSettingsPath(username) {
|
||||
const dir = userCacheDir(username);
|
||||
return dir ? dir + "/settings.json" : "";
|
||||
}
|
||||
|
||||
function _findUser(name) {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
if (users[i].username === name)
|
||||
return users[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _loadUsers() {
|
||||
Proc.runCommand("greeterUsersService-loadUsers", ["sh", "-c", "getent passwd | awk -F: '$3>=1000 && $3<60000 && $1!=\"nobody\" {print $1\":\"$3\":\"$5\":\"$6\":\"$7}'"], (output, exitCode) => {
|
||||
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
|
||||
const list = [];
|
||||
const names = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split(":");
|
||||
if (parts.length < 5)
|
||||
continue;
|
||||
const username = parts[0];
|
||||
list.push({
|
||||
username,
|
||||
uid: parseInt(parts[1], 10),
|
||||
gecos: (parts[2] || "").split(",")[0],
|
||||
home: parts[3] || "",
|
||||
shell: parts[4] || ""
|
||||
});
|
||||
names.push(username);
|
||||
}
|
||||
list.sort((a, b) => a.username.localeCompare(b.username));
|
||||
names.sort((a, b) => a.localeCompare(b));
|
||||
root.users = list;
|
||||
root.usernames = names;
|
||||
root.loaded = true;
|
||||
root.refreshing = false;
|
||||
_refreshSyncedThemeFlags();
|
||||
_loadProfileIcons();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _refreshSyncedThemeFlags() {
|
||||
if (usernames.length === 0) {
|
||||
syncedThemePaths = ({});
|
||||
return;
|
||||
}
|
||||
const checks = usernames.map(u => `[ -f "${syncedSettingsPath(u)}" ] && echo "${u}:1" || echo "${u}:0"`).join("; ");
|
||||
Proc.runCommand("greeterUsersService-syncedThemes", ["sh", "-c", checks], (output, exitCode) => {
|
||||
const map = {};
|
||||
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split(":");
|
||||
if (parts.length >= 2)
|
||||
map[parts[0]] = parts[1] === "1";
|
||||
}
|
||||
root.syncedThemePaths = map;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function profileImagePath(username) {
|
||||
if (!username)
|
||||
return "";
|
||||
return profileImageMap[username] || "";
|
||||
}
|
||||
|
||||
function _loadProfileIcons() {
|
||||
if (users.length === 0) {
|
||||
profileImageMap = ({});
|
||||
return;
|
||||
}
|
||||
const script = users.map(u => {
|
||||
const safeUser = u.username.replace(/'/g, "'\\''");
|
||||
const safeHome = (u.home || "").replace(/'/g, "'\\''");
|
||||
const cacheDir = usersCacheDir + "/" + u.username;
|
||||
return `( icon=""; for f in "${cacheDir}/profile.jpg" "${cacheDir}/profile.jpeg" "${cacheDir}/profile.png" "${cacheDir}/profile.webp" "/var/lib/AccountsService/icons/${safeUser}" "${safeHome}/.face" "${safeHome}/.face.icon"; do if [ -f "$f" ] && [ -r "$f" ]; then icon="$f"; break; fi; done; echo "${u.username}:$icon" )`;
|
||||
}).join("; ");
|
||||
Proc.runCommand("greeterUsersService-profileIcons", ["sh", "-c", script], (output, exitCode) => {
|
||||
const map = {};
|
||||
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const idx = lines[i].indexOf(":");
|
||||
if (idx <= 0)
|
||||
continue;
|
||||
const user = lines[i].substring(0, idx);
|
||||
const icon = lines[i].substring(idx + 1).trim();
|
||||
map[user] = icon && icon.length > 0 ? icon : "";
|
||||
}
|
||||
for (let j = 0; j < users.length; j++) {
|
||||
const u = users[j].username;
|
||||
if (!(u in map))
|
||||
map[u] = "";
|
||||
}
|
||||
root.profileImageMap = map;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
@@ -239,11 +239,23 @@ Singleton {
|
||||
});
|
||||
}
|
||||
|
||||
property string pendingGreeterProfileUser: ""
|
||||
|
||||
function getGreeterUserProfileImage(username) {
|
||||
if (!username) {
|
||||
profileImage = "";
|
||||
pendingGreeterProfileUser = "";
|
||||
return;
|
||||
}
|
||||
if (typeof GreeterUsersService !== "undefined") {
|
||||
const cachedPath = GreeterUsersService.profileImagePath(username);
|
||||
if (cachedPath) {
|
||||
profileImage = cachedPath;
|
||||
pendingGreeterProfileUser = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
pendingGreeterProfileUser = username;
|
||||
userProfileCheckProcess.command = ["bash", "-c", `uid=$(id -u ${username} 2>/dev/null) && [ -n "$uid" ] && dbus-send --system --print-reply --dest=org.freedesktop.Accounts /org/freedesktop/Accounts/User$uid org.freedesktop.DBus.Properties.Get string:org.freedesktop.Accounts.User string:IconFile 2>/dev/null | grep -oP 'string "\\K[^"]+' || echo ""`];
|
||||
userProfileCheckProcess.running = true;
|
||||
}
|
||||
@@ -261,12 +273,14 @@ Singleton {
|
||||
} else {
|
||||
root.profileImage = "";
|
||||
}
|
||||
root.pendingGreeterProfileUser = "";
|
||||
}
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode !== 0) {
|
||||
if (exitCode !== 0 && root.pendingGreeterProfileUser !== "") {
|
||||
root.profileImage = "";
|
||||
root.pendingGreeterProfileUser = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ Singleton {
|
||||
|
||||
property var users: []
|
||||
property string adminGroup: "wheel"
|
||||
property string greeterGroup: "greeter"
|
||||
property var adminMembers: []
|
||||
property var greeterMembers: []
|
||||
property bool refreshing: false
|
||||
|
||||
signal operationCompleted(string op, string username, bool success, string message)
|
||||
@@ -69,6 +71,21 @@ Singleton {
|
||||
Proc.runCommand("usersService-adminMembers", ["sh", "-c", "getent group " + root.adminGroup + " | awk -F: '{print $4}'"], (output, exitCode) => {
|
||||
const members = (output || "").trim().split(",").map(s => s.trim()).filter(s => s.length > 0);
|
||||
root.adminMembers = members;
|
||||
_detectGreeterGroup();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _detectGreeterGroup() {
|
||||
Proc.runCommand("usersService-detectGreeterGroup", ["sh", "-c", "getent group greeter >/dev/null 2>&1 && echo greeter || (getent group greetd >/dev/null 2>&1 && echo greetd || (getent group _greeter >/dev/null 2>&1 && echo _greeter || echo greeter))"], (output, exitCode) => {
|
||||
root.greeterGroup = (output || "").trim() || "greeter";
|
||||
_loadGreeterMembers();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _loadGreeterMembers() {
|
||||
Proc.runCommand("usersService-greeterMembers", ["sh", "-c", "getent group " + root.greeterGroup + " 2>/dev/null | awk -F: '{print $4}'"], (output, exitCode) => {
|
||||
const members = (output || "").trim().split(",").map(s => s.trim()).filter(s => s.length > 0);
|
||||
root.greeterMembers = members;
|
||||
_loadUsers();
|
||||
}, 0);
|
||||
}
|
||||
@@ -78,8 +95,11 @@ Singleton {
|
||||
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
|
||||
const list = [];
|
||||
const adminSet = {};
|
||||
const greeterSet = {};
|
||||
for (let i = 0; i < root.adminMembers.length; i++)
|
||||
adminSet[root.adminMembers[i]] = true;
|
||||
for (let i = 0; i < root.greeterMembers.length; i++)
|
||||
greeterSet[root.greeterMembers[i]] = true;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split(":");
|
||||
@@ -92,7 +112,8 @@ Singleton {
|
||||
gecos: (parts[2] || "").split(",")[0],
|
||||
home: parts[3] || "",
|
||||
shell: parts[4] || "",
|
||||
isAdmin: adminSet[username] === true
|
||||
isAdmin: adminSet[username] === true,
|
||||
isGreeter: greeterSet[username] === true
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => a.username.localeCompare(b.username));
|
||||
@@ -101,7 +122,7 @@ Singleton {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function createUser(username, password, addToAdmin, callback) {
|
||||
function createUser(username, password, addToAdmin, addToGreeter, callback) {
|
||||
if (!isValidUsername(username)) {
|
||||
_emit("create", username, false, I18n.tr("Invalid username"), callback);
|
||||
return;
|
||||
@@ -114,7 +135,7 @@ Singleton {
|
||||
_emit("create", username, false, I18n.tr("User already exists"), callback);
|
||||
return;
|
||||
}
|
||||
_runUseradd(username, password, addToAdmin === true, callback);
|
||||
_runUseradd(username, password, addToAdmin === true, addToGreeter === true, callback);
|
||||
}
|
||||
|
||||
function setPassword(username, newPassword, callback) {
|
||||
@@ -156,6 +177,55 @@ Singleton {
|
||||
_runAdminToggle(username, makeAdmin === true, callback);
|
||||
}
|
||||
|
||||
function setGreeterAccess(username, enable, callback) {
|
||||
if (!userExists(username)) {
|
||||
_emit("greeter", username, false, I18n.tr("User not found"), callback);
|
||||
return;
|
||||
}
|
||||
_runGreeterToggle(username, enable === true, callback);
|
||||
}
|
||||
|
||||
function _finishCreateUser(targetUser, addAdmin, addGreeter, outerCb) {
|
||||
function finish(success, message) {
|
||||
root._emit("create", targetUser, success, message, outerCb);
|
||||
}
|
||||
|
||||
function maybeGreeter(onDone) {
|
||||
if (addGreeter) {
|
||||
root._runGreeterToggle(targetUser, true, (greeterOk, greeterMsg) => {
|
||||
if (greeterOk)
|
||||
onDone();
|
||||
else
|
||||
finish(false, greeterMsg);
|
||||
});
|
||||
} else {
|
||||
onDone();
|
||||
}
|
||||
}
|
||||
|
||||
function createMessage() {
|
||||
if (addAdmin && addGreeter)
|
||||
return I18n.tr("User created with administrator and greeter login access");
|
||||
if (addAdmin)
|
||||
return I18n.tr("User created with administrator privileges");
|
||||
if (addGreeter)
|
||||
return I18n.tr("User created with greeter login access");
|
||||
return I18n.tr("User created");
|
||||
}
|
||||
|
||||
if (addAdmin) {
|
||||
root._runAdminToggle(targetUser, true, (adminOk, adminMsg) => {
|
||||
if (!adminOk) {
|
||||
finish(false, adminMsg);
|
||||
return;
|
||||
}
|
||||
maybeGreeter(() => finish(true, createMessage()));
|
||||
});
|
||||
} else {
|
||||
maybeGreeter(() => finish(true, createMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
function _emit(op, username, success, message, callback) {
|
||||
root.operationCompleted(op, username, success, message);
|
||||
if (typeof callback === "function") {
|
||||
@@ -174,6 +244,7 @@ Singleton {
|
||||
property string targetUser: ""
|
||||
property string targetPassword: ""
|
||||
property bool addAdmin: false
|
||||
property bool addGreeter: false
|
||||
property var cb: null
|
||||
property string capturedErr: ""
|
||||
running: false
|
||||
@@ -191,6 +262,7 @@ Singleton {
|
||||
const targetUser = useraddProc.targetUser;
|
||||
const targetPassword = useraddProc.targetPassword;
|
||||
const addAdmin = useraddProc.addAdmin;
|
||||
const addGreeter = useraddProc.addGreeter;
|
||||
const outerCb = useraddProc.cb;
|
||||
Qt.callLater(() => useraddProc.destroy());
|
||||
|
||||
@@ -199,17 +271,7 @@ Singleton {
|
||||
svc._emit("create", targetUser, false, pwMsg, outerCb);
|
||||
return;
|
||||
}
|
||||
if (addAdmin) {
|
||||
svc._runAdminToggle(targetUser, true, (adminOk, adminMsg) => {
|
||||
if (adminOk) {
|
||||
svc._emit("create", targetUser, true, I18n.tr("User created with administrator privileges"), outerCb);
|
||||
} else {
|
||||
svc._emit("create", targetUser, false, adminMsg, outerCb);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
svc._emit("create", targetUser, true, I18n.tr("User created"), outerCb);
|
||||
}
|
||||
svc._finishCreateUser(targetUser, addAdmin, addGreeter, outerCb);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -290,6 +352,36 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: greeterToggleComp
|
||||
Process {
|
||||
id: greeterToggleProc
|
||||
property string targetUser: ""
|
||||
property bool enableGreeter: false
|
||||
property var cb: null
|
||||
property string capturedErr: ""
|
||||
running: false
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: greeterToggleProc.capturedErr = text || ""
|
||||
}
|
||||
onExited: exitCode => {
|
||||
const targetUser = greeterToggleProc.targetUser;
|
||||
const enableGreeter = greeterToggleProc.enableGreeter;
|
||||
const cb = greeterToggleProc.cb;
|
||||
const err = (greeterToggleProc.capturedErr || "").trim();
|
||||
Qt.callLater(() => greeterToggleProc.destroy());
|
||||
|
||||
if (exitCode !== 0) {
|
||||
root._emit("greeter", targetUser, false, err || I18n.tr("usermod failed (exit %1)").arg(exitCode), cb);
|
||||
} else {
|
||||
root.refresh();
|
||||
root._emit("greeter", targetUser, true, enableGreeter ? I18n.tr("Granted greeter login access") : I18n.tr("Removed greeter login access"), cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: adminToggleComp
|
||||
Process {
|
||||
@@ -320,12 +412,13 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function _runUseradd(username, password, addToAdmin, callback) {
|
||||
function _runUseradd(username, password, addToAdmin, addToGreeter, callback) {
|
||||
const proc = useraddComp.createObject(root, {
|
||||
command: ["pkexec", "useradd", "-m", "-s", "/bin/bash", username],
|
||||
targetUser: username,
|
||||
targetPassword: password,
|
||||
addAdmin: addToAdmin,
|
||||
addGreeter: addToGreeter,
|
||||
cb: callback
|
||||
});
|
||||
proc.running = true;
|
||||
@@ -361,5 +454,16 @@ Singleton {
|
||||
proc.running = true;
|
||||
}
|
||||
|
||||
function _runGreeterToggle(username, enableGreeter, callback) {
|
||||
const cmd = enableGreeter ? ["pkexec", "usermod", "-aG", root.greeterGroup, username] : ["pkexec", "gpasswd", "-d", username, root.greeterGroup];
|
||||
const proc = greeterToggleComp.createObject(root, {
|
||||
command: cmd,
|
||||
targetUser: username,
|
||||
enableGreeter: enableGreeter,
|
||||
cb: callback
|
||||
});
|
||||
proc.running = true;
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,30 @@ Item {
|
||||
dropdownMenu.close();
|
||||
}
|
||||
|
||||
function openDropdownMenu() {
|
||||
if (dropdownMenu.visible) {
|
||||
dropdownMenu.close();
|
||||
return;
|
||||
}
|
||||
if (root.options.length === 0)
|
||||
return;
|
||||
|
||||
dropdownMenu.open();
|
||||
|
||||
let currentIndex = root.options.indexOf(root.currentValue);
|
||||
listView.positionViewAtIndex(currentIndex >= 0 ? currentIndex : 0, ListView.Beginning);
|
||||
|
||||
const pos = dropdown.mapToItem(Overlay.overlay, 0, 0);
|
||||
const popupW = dropdownMenu.width;
|
||||
const popupH = dropdownMenu.height;
|
||||
const overlayH = Overlay.overlay.height;
|
||||
const goUp = root.openUpwards || pos.y + dropdown.height + popupH + 4 > overlayH;
|
||||
dropdownMenu.x = root.alignPopupRight ? pos.x + dropdown.width - popupW : pos.x - (root.popupWidthOffset / 2);
|
||||
dropdownMenu.y = goUp ? pos.y - popupH - 4 : pos.y + dropdown.height + 4;
|
||||
if (root.enableFuzzySearch)
|
||||
searchField.forceActiveFocus();
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchField.text = "";
|
||||
dropdownMenu.fzfFinder = null;
|
||||
@@ -123,27 +147,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (dropdownMenu.visible) {
|
||||
dropdownMenu.close();
|
||||
return;
|
||||
}
|
||||
|
||||
dropdownMenu.open();
|
||||
|
||||
let currentIndex = root.options.indexOf(root.currentValue);
|
||||
listView.positionViewAtIndex(currentIndex, ListView.Beginning);
|
||||
|
||||
const pos = dropdown.mapToItem(Overlay.overlay, 0, 0);
|
||||
const popupW = dropdownMenu.width;
|
||||
const popupH = dropdownMenu.height;
|
||||
const overlayH = Overlay.overlay.height;
|
||||
const goUp = root.openUpwards || pos.y + dropdown.height + popupH + 4 > overlayH;
|
||||
dropdownMenu.x = root.alignPopupRight ? pos.x + dropdown.width - popupW : pos.x - (root.popupWidthOffset / 2);
|
||||
dropdownMenu.y = goUp ? pos.y - popupH - 4 : pos.y + dropdown.height + 4;
|
||||
if (root.enableFuzzySearch)
|
||||
searchField.forceActiveFocus();
|
||||
}
|
||||
onClicked: root.openDropdownMenu()
|
||||
}
|
||||
|
||||
Row {
|
||||
@@ -165,10 +169,10 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: root.currentValue
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.currentValue !== "" ? root.currentValue : root.emptyText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: root.currentValue !== "" ? Theme.surfaceText : Theme.outline
|
||||
width: contentRow.width - (contentRow.children[0].visible ? contentRow.children[0].width + contentRow.spacing : 0)
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.NoWrap
|
||||
|
||||
Reference in New Issue
Block a user