diff --git a/core/cmd/dms/commands_greeter.go b/core/cmd/dms/commands_greeter.go index 4a54bee9..a2e142de 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -59,22 +59,29 @@ 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, + 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. Also updates a per-user cache slot at users// for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users// 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) { diff --git a/core/internal/greeter/installer.go b/core/internal/greeter/installer.go index a95f388a..bc892590 100644 --- a/core/internal/greeter/installer.go +++ b/core/internal/greeter/installer.go @@ -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 } diff --git a/core/internal/greeter/user_cache_sync.go b/core/internal/greeter/user_cache_sync.go new file mode 100644 index 00000000..7e84faf6 --- /dev/null +++ b/core/internal/greeter/user_cache_sync.go @@ -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// +// 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 +} diff --git a/core/internal/greeter/user_cache_sync_test.go b/core/internal/greeter/user_cache_sync_test.go new file mode 100644 index 00000000..9d7b4a68 --- /dev/null +++ b/core/internal/greeter/user_cache_sync_test.go @@ -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"]) + } +} diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index 616a3fd5..4bc26c5e 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -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 diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index 44d4788a..0b0befad 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -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 diff --git a/quickshell/Modules/Greetd/GreetdSettings.qml b/quickshell/Modules/Greetd/GreetdSettings.qml index ea240cf2..abfb6580 100644 --- a/quickshell/Modules/Greetd/GreetdSettings.qml +++ b/quickshell/Modules/Greetd/GreetdSettings.qml @@ -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 diff --git a/quickshell/Modules/Greetd/GreeterContent.qml b/quickshell/Modules/Greetd/GreeterContent.qml index 7c313520..5423a57e 100644 --- a/quickshell/Modules/Greetd/GreeterContent.qml +++ b/quickshell/Modules/Greetd/GreeterContent.qml @@ -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,19 +820,26 @@ Item { anchors.fill: parent color: "transparent" - Item { - id: clockContainer - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.verticalCenter - anchors.bottomMargin: 60 - width: parent.width - height: clockText.implicitHeight + Column { + id: greeterMainColumn - Row { - id: clockText - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - spacing: 0 + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + width: 380 + + Item { + id: clockContainer + + width: parent.width + height: clockText.implicitHeight + + Row { + id: clockText + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: 0 property string fullTimeStr: { const format = GreetdSettings.getEffectiveTimeFormat(); @@ -853,60 +944,118 @@ Item { visible: clockText.ampm !== "" } } - } - - StyledText { - id: dateText - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: clockContainer.bottom - anchors.topMargin: 4 - text: { - return systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat()); } - font.pixelSize: Theme.fontSizeXLarge - color: "white" - opacity: 0.9 - } - Item { - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: dateText.bottom - anchors.topMargin: Theme.spacingL - width: 380 - height: 140 + StyledText { + id: dateText + + anchors.horizontalCenter: parent.horizontalCenter + text: systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat()) + font.pixelSize: Theme.fontSizeXLarge + color: "white" + opacity: 0.9 + } + + StyledText { + id: userPickerHint + + anchors.horizontalCenter: parent.horizontalCenter + 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 - imageSource: { - if (PortalService.profileImage === "") - return ""; - if (PortalService.profileImage.startsWith("/")) - return encodeFileUrl(PortalService.profileImage); - return PortalService.profileImage; + 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("/")) + return encodeFileUrl(PortalService.profileImage); + return PortalService.profileImage; + } + fallbackIcon: "person" + } + + 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; + } } - fallbackIcon: "person" - visible: GreetdSettings.lockScreenShowProfileImage } 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() } } } diff --git a/quickshell/Modules/Greetd/GreeterState.qml b/quickshell/Modules/Greetd/GreeterState.qml index ac57de74..57def95d 100644 --- a/quickshell/Modules/Greetd/GreeterState.qml +++ b/quickshell/Modules/Greetd/GreeterState.qml @@ -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; } } diff --git a/quickshell/Modules/Greetd/GreeterUserPicker.qml b/quickshell/Modules/Greetd/GreeterUserPicker.qml new file mode 100644 index 00000000..8c890db8 --- /dev/null +++ b/quickshell/Modules/Greetd/GreeterUserPicker.qml @@ -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) + } + } + } + } + } +} diff --git a/quickshell/Modules/Greetd/GreeterUserTheme.qml b/quickshell/Modules/Greetd/GreeterUserTheme.qml new file mode 100644 index 00000000..9ee9ae01 --- /dev/null +++ b/quickshell/Modules/Greetd/GreeterUserTheme.qml @@ -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" : ""; + } +} diff --git a/quickshell/Modules/Greetd/README.md b/quickshell/Modules/Greetd/README.md index 414cb904..1752ff77 100644 --- a/quickshell/Modules/Greetd/README.md +++ b/quickshell/Modules/Greetd/README.md @@ -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 `. Each added user must log out and back in before `--profile` will work. + +Per-user settings are stored under `/var/cache/dms-greeter/users//` 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: diff --git a/quickshell/Modules/Settings/GreeterTab.qml b/quickshell/Modules/Settings/GreeterTab.qml index 6c08dc5a..cdd6111d 100644 --- a/quickshell/Modules/Settings/GreeterTab.qml +++ b/quickshell/Modules/Settings/GreeterTab.qml @@ -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 diff --git a/quickshell/Modules/Settings/UsersTab.qml b/quickshell/Modules/Settings/UsersTab.qml index 5dfe7bd3..a07a26bd 100644 --- a/quickshell/Modules/Settings/UsersTab.qml +++ b/quickshell/Modules/Settings/UsersTab.qml @@ -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); } } diff --git a/quickshell/Services/GreeterUsersService.qml b/quickshell/Services/GreeterUsersService.qml new file mode 100644 index 00000000..2d58cba7 --- /dev/null +++ b/quickshell/Services/GreeterUsersService.qml @@ -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); + } +} diff --git a/quickshell/Services/PortalService.qml b/quickshell/Services/PortalService.qml index d3c16666..d7bd3512 100644 --- a/quickshell/Services/PortalService.qml +++ b/quickshell/Services/PortalService.qml @@ -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 = ""; } } } diff --git a/quickshell/Services/UsersService.qml b/quickshell/Services/UsersService.qml index 8735c8e9..3d4056b0 100644 --- a/quickshell/Services/UsersService.qml +++ b/quickshell/Services/UsersService.qml @@ -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() } diff --git a/quickshell/Widgets/DankDropdown.qml b/quickshell/Widgets/DankDropdown.qml index 05b962a0..acb8db0f 100644 --- a/quickshell/Widgets/DankDropdown.qml +++ b/quickshell/Widgets/DankDropdown.qml @@ -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