From acf63c57e8bfe6caf8375904bfb778c0006af336 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sun, 8 Mar 2026 22:28:32 -0400 Subject: [PATCH] fix(Greeter): Multi-distro reliability updates - Merge duplicate niri input/output KDL nodes instead of appending. Allows more overrides - Guard AppArmor install/uninstall behind IsAppArmorEnabled() check --- core/cmd/dms/commands_greeter.go | 118 +++--- core/internal/greeter/installer.go | 402 ++++++++++++++++--- quickshell/Modules/Greetd/GreetdMemory.qml | 2 +- quickshell/Modules/Greetd/GreeterContent.qml | 23 +- quickshell/Modules/Greetd/assets/dms-greeter | 21 +- quickshell/Modules/Settings/GreeterTab.qml | 32 +- 6 files changed, 476 insertions(+), 122 deletions(-) diff --git a/core/cmd/dms/commands_greeter.go b/core/cmd/dms/commands_greeter.go index 0dfdf60a..b0ee8146 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -227,9 +227,11 @@ func installGreeter(nonInteractive bool) error { return err } - fmt.Println("\nConfiguring AppArmor profile...") - if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { - logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + if greeter.IsAppArmorEnabled() { + fmt.Println("\nConfiguring AppArmor profile...") + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } } fmt.Println("\nConfiguring greetd...") @@ -575,12 +577,13 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { } } + if greeter.IsGreeterPackaged() && greeter.HasLegacyLocalGreeterWrapper() { + return fmt.Errorf("legacy manual wrapper detected at /usr/local/bin/dms-greeter; remove it before using packaged dms-greeter: sudo rm -f /usr/local/bin/dms-greeter") + } + cacheDir := greeter.GreeterCacheDir if _, err := os.Stat(cacheDir); os.IsNotExist(err) { logFunc("Cache directory not found — attempting to create it...") - if createErr := greeter.EnsureGreeterCacheDir(logFunc, ""); createErr != nil { - return fmt.Errorf("greeter cache directory not found at %s and could not be created: %w\nRun: sudo mkdir -p %s && sudo chown greeter:greeter %s", cacheDir, createErr, cacheDir, cacheDir) - } } greeterGroup := greeter.DetectGreeterGroup() @@ -600,27 +603,28 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { inGreeterGroup := strings.Contains(string(groupsOutput), greeterGroup) if !inGreeterGroup { if nonInteractive { - return fmt.Errorf("user must be in the %s group; run 'dms greeter sync' from a terminal to add", greeterGroup) - } - fmt.Printf("\n⚠ Warning: You are not in the %s group.\n", greeterGroup) - fmt.Printf("Would you like to add your user to the %s group? (Y/n): ", greeterGroup) - - var response string - fmt.Scanln(&response) - response = strings.ToLower(strings.TrimSpace(response)) - - if response != "n" && response != "no" { - fmt.Printf("\nAdding user to %s group...\n", greeterGroup) - addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username) - addUserCmd.Stdout = os.Stdout - addUserCmd.Stderr = os.Stderr - if err := addUserCmd.Run(); err != nil { - return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err) - } - fmt.Printf("✓ User added to %s group\n", greeterGroup) - fmt.Println("⚠ You will need to log out and back in for the group change to take effect") + logFunc(fmt.Sprintf("⚠ Not yet in %s group — will be added during sync (logout/login required to take effect).", greeterGroup)) } else { - return fmt.Errorf("aborted: user must be in the greeter group before syncing") + fmt.Printf("\n⚠ Warning: You are not in the %s group.\n", greeterGroup) + fmt.Printf("Would you like to add your user to the %s group? (Y/n): ", greeterGroup) + + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response != "n" && response != "no" { + fmt.Printf("\nAdding user to %s group...\n", greeterGroup) + addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username) + addUserCmd.Stdout = os.Stdout + addUserCmd.Stderr = os.Stderr + if err := addUserCmd.Run(); err != nil { + return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err) + } + fmt.Printf("✓ User added to %s group\n", greeterGroup) + fmt.Println("⚠ You will need to log out and back in for the group change to take effect") + } else { + return fmt.Errorf("aborted: user must be in the greeter group before syncing") + } } } } @@ -694,18 +698,25 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { } fmt.Println("\nSetting up permissions and ACLs...") + greeter.RemediateStaleACLs(logFunc, "") + greeter.RemediateStaleAppArmor(logFunc, "") if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { return err } + if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { + return fmt.Errorf("failed to ensure greeter cache directory at %s: %w\nRun: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s", cacheDir, err, cacheDir, greeterGroup, cacheDir, cacheDir) + } fmt.Println("\nSynchronizing DMS configurations...") if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, "", forceAuth); err != nil { return err } - fmt.Println("\nConfiguring AppArmor profile...") - if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { - logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + if greeter.IsAppArmorEnabled() { + fmt.Println("\nConfiguring AppArmor profile...") + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } } fmt.Println("\n=== Sync Complete ===") @@ -1021,6 +1032,7 @@ func enableGreeter(nonInteractive bool) error { logFunc := func(msg string) { fmt.Println(msg) } + greeterGroup := greeter.DetectGreeterGroup() if configAlreadyCorrect { fmt.Println("✓ Greeter is already configured with dms-greeter") @@ -1028,8 +1040,12 @@ func enableGreeter(nonInteractive bool) error { fmt.Printf("✓ Configured compositor: %s\n", configuredCompositor) } + fmt.Println("\nSetting up dms-greeter group and permissions...") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { - fmt.Printf("⚠ Could not create cache directory: %v\n Run: sudo mkdir -p %s && sudo chown greeter:greeter %s\n", err, greeter.GreeterCacheDir, greeter.GreeterCacheDir) + fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir) } if err := ensureGraphicalTarget(); err != nil { @@ -1100,12 +1116,18 @@ func enableGreeter(nonInteractive bool) error { return fmt.Errorf("failed to configure greetd: %w", err) } + fmt.Println("\nSetting up dms-greeter group and permissions...") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { - fmt.Printf("⚠ Could not create cache directory: %v\n Run: sudo mkdir -p %s && sudo chown greeter:greeter %s\n", err, greeter.GreeterCacheDir, greeter.GreeterCacheDir) + fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir) } - if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { - logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + if greeter.IsAppArmorEnabled() { + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } } if err := ensureGraphicalTarget(); err != nil { @@ -1540,30 +1562,33 @@ func checkGreeterStatus() error { fmt.Println(" - security key (U2F): disabled") } } else { - fmt.Println(" ℹ No managed auth block present (fingerprint/U2F disabled for greeter)") + fmt.Println(" ℹ No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)") } if legacyManaged { fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms greeter sync' to normalize.") allGood = false } includedFprintFile := greeter.DetectIncludedPamModule(string(pamData), "pam_fprintd.so") + showIncludedFprintNotice := false + if includedFprintFile != "" { + if enableFprint, _, settingsErr := greeter.ReadGreeterAuthToggles(homeDir); settingsErr == nil && enableFprint { + showIncludedFprintNotice = greeter.FingerprintAuthAvailableForCurrentUser() + } + } if managedFprint { if includedFprintFile != "" { fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", includedFprintFile) fmt.Println(" Double fingerprint auth detected — run 'dms greeter sync' to resolve.") allGood = false } - } else if includedFprintFile != "" { + } else if includedFprintFile != "" && showIncludedFprintNotice { fmt.Printf(" ℹ Fingerprint auth is enabled via included %s.\n", includedFprintFile) fmt.Println(" The DMS toggle only controls the managed block; disable fingerprint in authselect/pam-auth-update for password-only greeter login.") } } fmt.Println("\nSecurity (AppArmor):") - appArmorEnabled, appArmorErr := isAppArmorEnabled() - if appArmorErr != nil { - fmt.Printf(" ℹ Could not determine AppArmor status: %v\n", appArmorErr) - } else if !appArmorEnabled { + if !greeter.IsAppArmorEnabled() { fmt.Println(" ℹ AppArmor not enabled") } else { fmt.Println(" ℹ AppArmor is enabled") @@ -1612,18 +1637,6 @@ func checkGreeterStatus() error { return nil } -func isAppArmorEnabled() (bool, error) { - data, err := os.ReadFile("/sys/module/apparmor/parameters/enabled") - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - value := strings.TrimSpace(strings.ToLower(string(data))) - return strings.HasPrefix(value, "y"), nil -} - func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) { if sampleLimit <= 0 { sampleLimit = 3 @@ -1712,8 +1725,7 @@ func isGreeterRelatedAppArmorDenial(line string) bool { return false } -// appArmorProfileMode returns "complain", "enforce", or "" (unknown) for a named AppArmor -// profile by reading /sys/kernel/security/apparmor/profiles. +// appArmorProfileMode returns "complain", "enforce", or "" for a named AppArmor profile. func appArmorProfileMode(profileName string) string { data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles") if err != nil { diff --git a/core/internal/greeter/installer.go b/core/internal/greeter/installer.go index 56c4bf4d..49b0986c 100644 --- a/core/internal/greeter/installer.go +++ b/core/internal/greeter/installer.go @@ -208,7 +208,7 @@ func DetectGreeterUser() string { } } - if user, found := findPasswdUser(passwdContent, "greeter", "_greeter", "greetd"); found { + if user, found := findPasswdUser(passwdContent, "greeter", "greetd", "_greeter"); found { return user } } else { @@ -230,6 +230,16 @@ func resolveGreeterWrapperPath() string { return override } + // Packaged installs only use the official wrapper; never fall back to /usr/local/bin. + if IsGreeterPackaged() { + packagedWrapper := "/usr/bin/dms-greeter" + if info, err := os.Stat(packagedWrapper); err == nil && !info.IsDir() && (info.Mode()&0o111) != 0 { + return packagedWrapper + } + fmt.Fprintln(os.Stderr, "⚠ Warning: packaged dms-greeter detected, but /usr/bin/dms-greeter is missing or not executable") + return packagedWrapper + } + for _, candidate := range []string{"/usr/bin/dms-greeter", "/usr/local/bin/dms-greeter"} { if info, err := os.Stat(candidate); err == nil && !info.IsDir() && (info.Mode()&0o111) != 0 { return candidate @@ -558,54 +568,124 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass } // EnsureGreeterCacheDir creates /var/cache/dms-greeter with correct ownership if it does not exist. -// It is safe to call multiple times (idempotent). +// It is safe to call multiple times (idempotent) and will repair ownership/mode +// when the directory already exists with stale permissions. func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error { cacheDir := GreeterCacheDir - if _, err := os.Stat(cacheDir); err == nil { - return nil - } - - if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil { - return fmt.Errorf("failed to create cache directory: %w", err) + created := false + if info, err := os.Stat(cacheDir); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat cache directory: %w", err) + } + if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + created = true + } else if !info.IsDir() { + return fmt.Errorf("cache path exists but is not a directory: %s", cacheDir) } group := DetectGreeterGroup() - owner := fmt.Sprintf("%s:%s", group, group) - + daemonUser := DetectGreeterUser() + preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group) + owner := preferredOwner if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil { - return fmt.Errorf("failed to set cache directory owner: %w", err) + // Some setups may not have a matching daemon user at this moment; fall back + // to root: while still allowing group-writable greeter runtime access. + fallbackOwner := fmt.Sprintf("root:%s", group) + if fallbackErr := runSudoCmd(sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil { + return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr) + } + owner = fallbackOwner } - if err := runSudoCmd(sudoPassword, "chmod", "750", cacheDir); err != nil { + if err := runSudoCmd(sudoPassword, "chmod", "2770", cacheDir); err != nil { return fmt.Errorf("failed to set cache directory permissions: %w", err) } - logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: %s, mode: 750)", cacheDir, owner)) + + runtimeDirs := []string{ + filepath.Join(cacheDir, ".local"), + filepath.Join(cacheDir, ".local", "state"), + filepath.Join(cacheDir, ".local", "share"), + filepath.Join(cacheDir, ".cache"), + } + for _, dir := range runtimeDirs { + if err := runSudoCmd(sudoPassword, "mkdir", "-p", dir); err != nil { + return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err) + } + if err := runSudoCmd(sudoPassword, "chown", owner, dir); err != nil { + return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err) + } + if err := runSudoCmd(sudoPassword, "chmod", "2770", dir); err != nil { + return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err) + } + } + + legacyMemoryPath := filepath.Join(cacheDir, "memory.json") + stateMemoryPath := filepath.Join(cacheDir, ".local", "state", "memory.json") + if err := ensureGreeterMemoryCompatLink(logFunc, sudoPassword, legacyMemoryPath, stateMemoryPath); err != nil { + return err + } + + if isSELinuxEnforcing() && utils.CommandExists("restorecon") { + if err := runSudoCmd(sudoPassword, "restorecon", "-Rv", cacheDir); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err)) + } + } + + if created { + logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: %s, mode: 2770)", cacheDir, owner)) + } else { + logFunc(fmt.Sprintf("✓ Ensured cache directory %s permissions (owner: %s, mode: 2770)", cacheDir, owner)) + } return nil } -// InstallAppArmorProfile writes the bundled AppArmor profile for dms-greeter and reloads -// it with apparmor_parser. It is safe to call multiple times (idempotent reload). -// -// Skipped silently when: -// - AppArmor kernel module is absent (/sys/module/apparmor does not exist) -// - Running on NixOS (profiles are managed via security.apparmor.policies) -// - SELinux is active (/sys/fs/selinux/enforce exists and equals "1") — Fedora/RHEL +func isSELinuxEnforcing() bool { + data, err := os.ReadFile("/sys/fs/selinux/enforce") + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == "1" +} + +func ensureGreeterMemoryCompatLink(logFunc func(string), sudoPassword, legacyPath, statePath string) error { + info, err := os.Lstat(legacyPath) + if err == nil && info.Mode().IsRegular() { + if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) { + if copyErr := runSudoCmd(sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr)) + } + } + } + + if err := runSudoCmd(sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil { + return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err) + } + + return nil +} + +// IsAppArmorEnabled reports whether AppArmor is active on the running kernel. +func IsAppArmorEnabled() bool { + data, err := os.ReadFile("/sys/module/apparmor/parameters/enabled") + if err != nil { + return false + } + return strings.HasPrefix(strings.TrimSpace(strings.ToLower(string(data))), "y") +} + +// InstallAppArmorProfile installs the bundled AppArmor profile and reloads it. No-op on NixOS or non-AppArmor systems. func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error { if IsNixOS() { logFunc(" ℹ Skipping AppArmor profile on NixOS (manage via security.apparmor.policies)") return nil } - if _, err := os.Stat("/sys/module/apparmor"); os.IsNotExist(err) { + if !IsAppArmorEnabled() { return nil } - if data, err := os.ReadFile("/sys/fs/selinux/enforce"); err == nil { - if strings.TrimSpace(string(data)) == "1" { - return nil - } - } - if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil { return fmt.Errorf("failed to create /etc/apparmor.d: %w", err) } @@ -814,7 +894,7 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error { {filepath.Join(homeDir, ".local", "share"), ".local/share directory"}, } - owner := DetectGreeterGroup() + group := DetectGreeterGroup() logFunc("\nSetting up parent directory ACLs for greeter user access...") @@ -826,9 +906,10 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error { } } - if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("u:%s:rx", owner), dir.path); err != nil { + // Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora). + if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil { logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err)) - logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m u:%s:x %s", owner, dir.path)) + logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path)) continue } @@ -838,6 +919,73 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error { return nil } +// RemediateStaleACLs removes user-based ACLs left by older binary versions. Best-effort. +func RemediateStaleACLs(logFunc func(string), sudoPassword string) { + if !utils.CommandExists("setfacl") { + return + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return + } + + passwdData, err := os.ReadFile("/etc/passwd") + if err != nil { + return + } + + dirs := []string{ + homeDir, + filepath.Join(homeDir, ".config"), + filepath.Join(homeDir, ".config", "DankMaterialShell"), + filepath.Join(homeDir, ".cache"), + filepath.Join(homeDir, ".cache", "DankMaterialShell"), + filepath.Join(homeDir, ".local"), + filepath.Join(homeDir, ".local", "state"), + filepath.Join(homeDir, ".local", "share"), + } + + passwdContent := string(passwdData) + staleUsers := []string{"greeter", "greetd", "_greeter"} + existingUsers := make([]string, 0, len(staleUsers)) + for _, user := range staleUsers { + if hasPasswdUser(passwdContent, user) { + existingUsers = append(existingUsers, user) + } + } + if len(existingUsers) == 0 { + return + } + + cleaned := false + for _, dir := range dirs { + if _, err := os.Stat(dir); err != nil { + continue + } + for _, user := range existingUsers { + _ = runSudoCmd(sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir) + cleaned = true + } + } + if cleaned { + logFunc("✓ Cleaned up stale user-based ACLs from previous versions") + } +} + +// RemediateStaleAppArmor removes any AppArmor profile installed by an older binary on +// systems where AppArmor is not active. +func RemediateStaleAppArmor(logFunc func(string), sudoPassword string) { + if IsAppArmorEnabled() { + return + } + if _, err := os.Stat(appArmorProfileDest); os.IsNotExist(err) { + return + } + logFunc("ℹ Removing stale AppArmor profile (AppArmor is not active on this system)") + _ = UninstallAppArmorProfile(logFunc, sudoPassword) +} + func SetupDMSGroup(logFunc func(string), sudoPassword string) error { homeDir, err := os.UserHomeDir() if err != nil { @@ -854,6 +1002,14 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error { group := DetectGreeterGroup() + // Create the group if it doesn't exist yet (e.g. before greetd package is installed). + if !utils.HasGroup(group) { + if err := runSudoCmd(sudoPassword, "groupadd", "-r", group); err != nil { + return fmt.Errorf("failed to create %s group: %w", group, err) + } + logFunc(fmt.Sprintf("✓ Created system group %s", group)) + } + groupsCmd := exec.Command("groups", currentUser) groupsOutput, err := groupsCmd.Output() if err == nil && strings.Contains(string(groupsOutput), group) { @@ -865,6 +1021,24 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error { logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group)) } + // Also add the daemon user (e.g. greetd on Fedora) so group ACLs apply to the running process. + daemonUser := DetectGreeterUser() + if daemonUser != currentUser { + daemonGroupsCmd := exec.Command("groups", daemonUser) + daemonGroupsOutput, daemonGroupsErr := daemonGroupsCmd.Output() + if daemonGroupsErr == nil { + if strings.Contains(string(daemonGroupsOutput), group) { + logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group)) + } else { + if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, daemonUser); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err)) + } else { + logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group)) + } + } + } + } + configDirs := []struct { path string desc string @@ -1018,8 +1192,11 @@ func syncGreeterWallpaperOverride(homeDir, cacheDir string, logFunc func(string) return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err) } greeterGroup := DetectGreeterGroup() - if err := runSudoCmd(sudoPassword, "chown", "greeter:"+greeterGroup, destPath); err != nil { - return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err) + daemonUser := DetectGreeterUser() + if err := runSudoCmd(sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil { + if fallbackErr := runSudoCmd(sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil { + return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err) + } } if err := runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil { return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err) @@ -1158,28 +1335,107 @@ func DetectIncludedPamModule(pamText, module string) string { return "" } +type greeterAuthSettings struct { + GreeterEnableFprint bool `json:"greeterEnableFprint"` + GreeterEnableU2f bool `json:"greeterEnableU2f"` +} + +func readGreeterAuthSettings(homeDir string) (greeterAuthSettings, error) { + settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return greeterAuthSettings{}, nil + } + return greeterAuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return greeterAuthSettings{}, nil + } + var settings greeterAuthSettings + if err := json.Unmarshal(data, &settings); err != nil { + return greeterAuthSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err) + } + return settings, nil +} + +func ReadGreeterAuthToggles(homeDir string) (enableFprint bool, enableU2f bool, err error) { + settings, err := readGreeterAuthSettings(homeDir) + if err != nil { + return false, false, err + } + return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil +} + +func hasEnrolledFingerprintOutput(output string) bool { + lower := strings.ToLower(output) + if strings.Contains(lower, "no fingers enrolled") || + strings.Contains(lower, "no fingerprints enrolled") || + strings.Contains(lower, "no prints enrolled") { + return false + } + if strings.Contains(lower, "has fingers enrolled") || + strings.Contains(lower, "has fingerprints enrolled") { + return true + } + for _, line := range strings.Split(lower, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "finger:") { + return true + } + if strings.HasPrefix(trimmed, "- ") && strings.Contains(trimmed, "finger") { + return true + } + } + return false +} + +func FingerprintAuthAvailableForUser(username string) bool { + username = strings.TrimSpace(username) + if username == "" { + return false + } + if !pamModuleExists("pam_fprintd.so") { + return false + } + if _, err := exec.LookPath("fprintd-list"); err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "fprintd-list", username).CombinedOutput() + if err != nil { + return false + } + return hasEnrolledFingerprintOutput(string(out)) +} + +func FingerprintAuthAvailableForCurrentUser() bool { + username := strings.TrimSpace(os.Getenv("SUDO_USER")) + if username == "" { + username = strings.TrimSpace(os.Getenv("USER")) + } + if username == "" { + out, err := exec.Command("id", "-un").Output() + if err == nil { + username = strings.TrimSpace(string(out)) + } + } + return FingerprintAuthAvailableForUser(username) +} + func syncGreeterPamConfig(homeDir string, logFunc func(string), sudoPassword string, forceAuth bool) error { var wantFprint, wantU2f bool + fprintToggleEnabled := forceAuth if forceAuth { wantFprint = pamModuleExists("pam_fprintd.so") wantU2f = pamModuleExists("pam_u2f.so") } else { - settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") - data, err := os.ReadFile(settingsPath) + settings, err := readGreeterAuthSettings(homeDir) if err != nil { - if os.IsNotExist(err) { - data = []byte("{}") - } else { - return fmt.Errorf("failed to read settings at %s: %w", settingsPath, err) - } - } - var settings struct { - GreeterEnableFprint bool `json:"greeterEnableFprint"` - GreeterEnableU2f bool `json:"greeterEnableU2f"` - } - if err := json.Unmarshal(data, &settings); err != nil { - return fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err) + return err } + fprintToggleEnabled = settings.GreeterEnableFprint fprintModule := pamModuleExists("pam_fprintd.so") u2fModule := pamModuleExists("pam_u2f.so") wantFprint = settings.GreeterEnableFprint && fprintModule @@ -1212,7 +1468,8 @@ func syncGreeterPamConfig(homeDir string, logFunc func(string), sudoPassword str logFunc("⚠ pam_fprintd already present in included " + includedFprintFile + " (managed by authselect/pam-auth-update). Skipping DMS fprint block to avoid double-fingerprint auth.") wantFprint = false } - if !wantFprint && includedFprintFile != "" { + showIncludedFprintNotice := fprintToggleEnabled && FingerprintAuthAvailableForCurrentUser() + if !wantFprint && includedFprintFile != "" && showIncludedFprintNotice { logFunc("ℹ Fingerprint auth is still enabled via included " + includedFprintFile + ".") logFunc(" Disable fingerprint in your system PAM manager (authselect/pam-auth-update) to force password-only greeter login.") } @@ -1272,6 +1529,8 @@ type niriGreeterSync struct { cursorCount int debugCount int cursorNode *document.Node + inputNode *document.Node + outputNodes map[string]*document.Node } func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error { @@ -1289,7 +1548,8 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error { } extractor := &niriGreeterSync{ - processed: make(map[string]bool), + processed: make(map[string]bool), + outputNodes: make(map[string]*document.Node), } if err := extractor.processFile(configPath); err != nil { @@ -1488,10 +1748,22 @@ func (s *niriGreeterSync) processFile(filePath string) error { return err } case "input": - s.nodes = append(s.nodes, node) + if s.inputNode == nil { + s.inputNode = node + s.inputNode.Children = dedupeCursorChildren(s.inputNode.Children) + s.nodes = append(s.nodes, node) + } else if len(node.Children) > 0 { + s.inputNode.Children = mergeInputChildren(s.inputNode.Children, node.Children) + } s.inputCount++ case "output": - s.nodes = append(s.nodes, node) + key := outputNodeKey(node) + if existing, ok := s.outputNodes[key]; ok { + *existing = *node + } else { + s.outputNodes[key] = node + s.nodes = append(s.nodes, node) + } s.outputCount++ case "cursor": if s.cursorNode == nil { @@ -1554,6 +1826,36 @@ func dedupeCursorChildren(children []*document.Node) []*document.Node { return result } +func mergeInputChildren(existing []*document.Node, incoming []*document.Node) []*document.Node { + if len(incoming) == 0 { + return existing + } + + indexByName := make(map[string]int, len(existing)) + for i, child := range existing { + indexByName[child.Name.String()] = i + } + + for _, child := range incoming { + name := child.Name.String() + if idx, ok := indexByName[name]; ok { + existing[idx] = child + continue + } + indexByName[name] = len(existing) + existing = append(existing, child) + } + + return existing +} + +func outputNodeKey(node *document.Node) string { + if len(node.Arguments) > 0 { + return strings.Trim(node.Arguments[0].String(), "\"") + } + return "" +} + func (s *niriGreeterSync) handleInclude(node *document.Node, baseDir string) error { if len(node.Arguments) == 0 { return nil diff --git a/quickshell/Modules/Greetd/GreetdMemory.qml b/quickshell/Modules/Greetd/GreetdMemory.qml index 6c8f9cb5..541e6af5 100644 --- a/quickshell/Modules/Greetd/GreetdMemory.qml +++ b/quickshell/Modules/Greetd/GreetdMemory.qml @@ -11,7 +11,7 @@ Singleton { readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter" readonly property string sessionConfigPath: greetCfgDir + "/session.json" - readonly property string memoryFile: greetCfgDir + "/memory.json" + readonly property string memoryFile: greetCfgDir + "/.local/state/memory.json" readonly property bool rememberLastSession: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_SESSION", "DMS_SAVE_SESSION"], true) readonly property bool rememberLastUser: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_USER", "DMS_SAVE_USERNAME"], true) diff --git a/quickshell/Modules/Greetd/GreeterContent.qml b/quickshell/Modules/Greetd/GreeterContent.qml index 926450c4..031ee0ac 100644 --- a/quickshell/Modules/Greetd/GreeterContent.qml +++ b/quickshell/Modules/Greetd/GreeterContent.qml @@ -37,6 +37,9 @@ Item { property bool cancelingExternalAuthForPassword: false property int defaultAuthTimeoutMs: 12000 property int externalAuthTimeoutMs: 45000 + property int memoryFlushDelayMs: 120 + property string pendingLaunchCommand: "" + property var pendingLaunchEnv: [] property int passwordFailureCount: 0 property int passwordAttemptLimitHint: 0 property string authFeedbackMessage: "" @@ -49,7 +52,7 @@ Item { property string externalAuthAutoStartedForUser: "" readonly property bool greeterPamHasFprint: pamModuleEnabled(greetdPamText, "pam_fprintd") || (greetdPamText.includes("system-auth") && pamModuleEnabled(systemAuthPamText, "pam_fprintd")) || (greetdPamText.includes("common-auth") && pamModuleEnabled(commonAuthPamText, "pam_fprintd")) || (greetdPamText.includes("password-auth") && pamModuleEnabled(passwordAuthPamText, "pam_fprintd")) readonly property bool greeterPamHasU2f: pamModuleEnabled(greetdPamText, "pam_u2f") || (greetdPamText.includes("system-auth") && pamModuleEnabled(systemAuthPamText, "pam_u2f")) || (greetdPamText.includes("common-auth") && pamModuleEnabled(commonAuthPamText, "pam_u2f")) || (greetdPamText.includes("password-auth") && pamModuleEnabled(passwordAuthPamText, "pam_u2f")) - readonly property bool greeterExternalAuthAvailable: greeterPamHasFprint || greeterPamHasU2f + readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f) function initWeatherService() { if (weatherInitialized) @@ -1618,7 +1621,9 @@ Item { } else if (GreetdMemory.lastSuccessfulUser) { GreetdMemory.setLastSuccessfulUser(""); } - Greetd.launch(sessionCmd.split(" "), ["XDG_SESSION_TYPE=wayland"]); + pendingLaunchCommand = sessionCmd; + pendingLaunchEnv = ["XDG_SESSION_TYPE=wayland"]; + memoryFlushTimer.restart(); } function onAuthFailure(message) { @@ -1661,6 +1666,20 @@ Item { } } + Timer { + id: memoryFlushTimer + interval: memoryFlushDelayMs + onTriggered: { + if (!pendingLaunchCommand) + return; + const sessionCommand = pendingLaunchCommand; + const launchEnv = pendingLaunchEnv; + pendingLaunchCommand = ""; + pendingLaunchEnv = []; + Greetd.launch(sessionCommand.split(" "), launchEnv); + } + } + Timer { id: authTimeout interval: defaultAuthTimeoutMs diff --git a/quickshell/Modules/Greetd/assets/dms-greeter b/quickshell/Modules/Greetd/assets/dms-greeter index a6091d00..f3aeff8f 100755 --- a/quickshell/Modules/Greetd/assets/dms-greeter +++ b/quickshell/Modules/Greetd/assets/dms-greeter @@ -179,6 +179,22 @@ export QT_QPA_PLATFORM=wayland export QT_WAYLAND_DISABLE_WINDOWDECORATION=1 export EGL_PLATFORM=gbm export DMS_RUN_GREETER=1 + +ensure_cache_tree() { + local base="$1" + mkdir -p "$base/.local/state" "$base/.local/share" "$base/.cache" +} + +if ! ensure_cache_tree "$CACHE_DIR" 2>/dev/null; then + FALLBACK_CACHE_DIR="/tmp/dms-greeter-${UID:-$(id -u)}" + echo "Warning: cache directory '$CACHE_DIR' is not writable; falling back to '$FALLBACK_CACHE_DIR'" >&2 + CACHE_DIR="$FALLBACK_CACHE_DIR" + if ! ensure_cache_tree "$CACHE_DIR"; then + echo "Error: failed to initialize fallback cache directory '$CACHE_DIR'" >&2 + exit 1 + fi +fi + export DMS_GREET_CFG_DIR="$CACHE_DIR" if [[ -n "$REMEMBER_LAST_SESSION" ]]; then @@ -203,11 +219,6 @@ if [[ -n "$REMEMBER_LAST_USER" ]]; then export DMS_SAVE_USERNAME fi -mkdir -p "$CACHE_DIR" -mkdir -p "$CACHE_DIR/.local/state" -mkdir -p "$CACHE_DIR/.local/share" -mkdir -p "$CACHE_DIR/.cache" - export HOME="$CACHE_DIR" export XDG_STATE_HOME="$CACHE_DIR/.local/state" export XDG_DATA_HOME="$CACHE_DIR/.local/share" diff --git a/quickshell/Modules/Settings/GreeterTab.qml b/quickshell/Modules/Settings/GreeterTab.qml index 72654836..baace5c0 100644 --- a/quickshell/Modules/Settings/GreeterTab.qml +++ b/quickshell/Modules/Settings/GreeterTab.qml @@ -49,18 +49,24 @@ Item { readonly property bool greeterInstalled: greeterBinaryExists || greeterEnabled readonly property string greeterActionLabel: { - if (!root.greeterInstalled) return I18n.tr("Install"); - if (!root.greeterEnabled) return I18n.tr("Activate"); + if (!root.greeterInstalled) + return I18n.tr("Install"); + if (!root.greeterEnabled) + return I18n.tr("Activate"); return I18n.tr("Uninstall"); } readonly property string greeterActionIcon: { - if (!root.greeterInstalled) return "download"; - if (!root.greeterEnabled) return "login"; + if (!root.greeterInstalled) + return "download"; + if (!root.greeterEnabled) + return "login"; return "delete"; } readonly property var greeterActionCommand: { - if (!root.greeterInstalled) return ["dms", "greeter", "install", "--terminal"]; - if (!root.greeterEnabled) return ["dms", "greeter", "enable", "--terminal"]; + if (!root.greeterInstalled) + return ["dms", "greeter", "install", "--terminal"]; + if (!root.greeterEnabled) + return ["dms", "greeter", "enable", "--terminal"]; return ["dms", "greeter", "uninstall", "--terminal", "--yes"]; } property string greeterPendingAction: "" @@ -79,9 +85,7 @@ Item { } function runGreeterInstallAction() { - root.greeterPendingAction = !root.greeterInstalled ? "install" - : !root.greeterEnabled ? "activate" - : "uninstall"; + root.greeterPendingAction = !root.greeterInstalled ? "install" : !root.greeterEnabled ? "activate" : "uninstall"; greeterStatusText = I18n.tr("Opening terminal: ") + root.greeterActionLabel + "…"; greeterInstallActionRunning = true; greeterInstallActionProcess.running = true; @@ -241,6 +245,7 @@ Item { root.greeterStatusText = failure; root.launchGreeterSyncTerminalFallback(false, ""); } + root.checkGreeterInstallState(); } } @@ -406,7 +411,10 @@ Item { } } - Item { width: 1; height: Theme.spacingM } + Item { + width: 1 + height: Theme.spacingM + } RowLayout { width: parent.width @@ -420,7 +428,9 @@ Item { enabled: !root.greeterInstallActionRunning && !root.greeterSyncRunning } - Item { Layout.fillWidth: true } + Item { + Layout.fillWidth: true + } DankButton { text: I18n.tr("Refresh")