From 70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 Mon Sep 17 00:00:00 2001 From: AlexanderCurl Date: Sat, 18 Apr 2026 17:46:06 +0100 Subject: Replaced file structures for themes in the correct format --- .../core/internal/greeter/installer.go | 2076 ++++++++++++++++++++ 1 file changed, 2076 insertions(+) create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/greeter/installer.go (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/greeter/installer.go') diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/greeter/installer.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/greeter/installer.go new file mode 100644 index 0000000..ace72ce --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/greeter/installer.go @@ -0,0 +1,2076 @@ +package greeter + +import ( + "bufio" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/sblinch/kdl-go" + "github.com/sblinch/kdl-go/document" +) + +var appArmorProfileData []byte + +const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter" + +const GreeterCacheDir = "/var/cache/dms-greeter" + +func DetectDMSPath() (string, error) { + return config.LocateDMSConfig() +} + +// IsNixOS returns true when running on NixOS, which manages PAM configs through +// its module system. The DMS PAM managed block won't be written on NixOS. +func IsNixOS() bool { + _, err := os.Stat("/etc/NIXOS") + return err == nil +} + +func DetectGreeterGroup() string { + data, err := os.ReadFile("/etc/group") + if err != nil { + fmt.Fprintln(os.Stderr, "⚠ Warning: could not read /etc/group, defaulting to greeter") + return "greeter" + } + + if group, found := utils.FindGroupData(string(data), "greeter", "greetd", "_greeter"); found { + return group + } + + fmt.Fprintln(os.Stderr, "⚠ Warning: no greeter group found in /etc/group, defaulting to greeter") + return "greeter" +} + +func hasPasswdUser(passwdData, user string) bool { + prefix := user + ":" + for line := range strings.SplitSeq(passwdData, "\n") { + if strings.HasPrefix(line, prefix) { + return true + } + } + return false +} + +func findPasswdUser(passwdData string, candidates ...string) (string, bool) { + for _, candidate := range candidates { + if hasPasswdUser(passwdData, candidate) { + return candidate, true + } + } + return "", false +} + +func stripTomlComment(line string) string { + trimmed := strings.TrimSpace(line) + if idx := strings.Index(trimmed, "#"); idx >= 0 { + return strings.TrimSpace(trimmed[:idx]) + } + return trimmed +} + +func parseTomlSection(line string) (string, bool) { + trimmed := stripTomlComment(line) + if len(trimmed) < 3 || !strings.HasPrefix(trimmed, "[") || !strings.HasSuffix(trimmed, "]") { + return "", false + } + return strings.TrimSpace(trimmed[1 : len(trimmed)-1]), true +} + +func extractDefaultSessionUser(configContent string) string { + inDefaultSession := false + for line := range strings.SplitSeq(configContent, "\n") { + if section, ok := parseTomlSection(line); ok { + inDefaultSession = section == "default_session" + continue + } + + if !inDefaultSession { + continue + } + + trimmed := stripTomlComment(line) + if !strings.HasPrefix(trimmed, "user =") && !strings.HasPrefix(trimmed, "user=") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + continue + } + user := strings.Trim(strings.TrimSpace(parts[1]), `"`) + if user != "" { + return user + } + } + + return "" +} + +func upsertDefaultSession(configContent, greeterUser, command string) string { + lines := strings.Split(configContent, "\n") + var out []string + + inDefaultSession := false + foundDefaultSession := false + defaultSessionUserSet := false + defaultSessionCommandSet := false + + appendDefaultSessionFields := func() { + if !defaultSessionUserSet { + out = append(out, fmt.Sprintf(`user = "%s"`, greeterUser)) + } + if !defaultSessionCommandSet { + out = append(out, command) + } + } + + for _, line := range lines { + if section, ok := parseTomlSection(line); ok { + if inDefaultSession { + appendDefaultSessionFields() + } + + inDefaultSession = section == "default_session" + if inDefaultSession { + foundDefaultSession = true + defaultSessionUserSet = false + defaultSessionCommandSet = false + } + + out = append(out, line) + continue + } + + if inDefaultSession { + trimmed := stripTomlComment(line) + if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") { + out = append(out, fmt.Sprintf(`user = "%s"`, greeterUser)) + defaultSessionUserSet = true + continue + } + + if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") { + if !defaultSessionCommandSet { + out = append(out, command) + defaultSessionCommandSet = true + } + continue + } + } + + out = append(out, line) + } + + if inDefaultSession { + appendDefaultSessionFields() + } + + if !foundDefaultSession { + if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { + out = append(out, "") + } + out = append(out, "[default_session]") + out = append(out, fmt.Sprintf(`user = "%s"`, greeterUser)) + out = append(out, command) + } + + return strings.Join(out, "\n") +} + +func DetectGreeterUser() string { + passwdData, err := os.ReadFile("/etc/passwd") + if err == nil { + passwdContent := string(passwdData) + + if configData, cfgErr := os.ReadFile("/etc/greetd/config.toml"); cfgErr == nil { + if configured := extractDefaultSessionUser(string(configData)); configured != "" && hasPasswdUser(passwdContent, configured) { + return configured + } + } + + if user, found := findPasswdUser(passwdContent, "greeter", "greetd", "_greeter"); found { + return user + } + } else { + fmt.Fprintln(os.Stderr, "⚠ Warning: could not read /etc/passwd, defaulting greeter user to 'greeter'") + } + + if configData, cfgErr := os.ReadFile("/etc/greetd/config.toml"); cfgErr == nil { + if configured := extractDefaultSessionUser(string(configData)); configured != "" { + return configured + } + } + + fmt.Fprintln(os.Stderr, "⚠ Warning: no greeter user found, defaulting to 'greeter'") + return "greeter" +} + +func resolveGreeterWrapperPath() string { + if override := strings.TrimSpace(os.Getenv("DMS_GREETER_WRAPPER_CMD")); override != "" { + 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 + } + } + + if path, err := exec.LookPath("dms-greeter"); err == nil { + resolved := path + if realPath, realErr := filepath.EvalSymlinks(path); realErr == nil { + resolved = realPath + } + if strings.HasPrefix(resolved, "/home/") || strings.HasPrefix(resolved, "/tmp/") { + fmt.Fprintf(os.Stderr, "⚠ Warning: ignoring non-system dms-greeter on PATH: %s\n", path) + } else { + return path + } + } + + return "/usr/bin/dms-greeter" +} + +func DetectCompositors() []string { + var compositors []string + + if utils.CommandExists("niri") { + compositors = append(compositors, "niri") + } + if utils.CommandExists("Hyprland") { + compositors = append(compositors, "Hyprland") + } + + return compositors +} + +func PromptCompositorChoice(compositors []string) (string, error) { + fmt.Println("\nMultiple compositors detected:") + for i, comp := range compositors { + fmt.Printf("%d) %s\n", i+1, comp) + } + + reader := bufio.NewReader(os.Stdin) + fmt.Print("Choose compositor for greeter (1-2): ") + response, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("error reading input: %w", err) + } + + response = strings.TrimSpace(response) + switch response { + case "1": + return compositors[0], nil + case "2": + if len(compositors) > 1 { + return compositors[1], nil + } + return "", fmt.Errorf("invalid choice") + default: + return "", fmt.Errorf("invalid choice") + } +} + +// EnsureGreetdInstalled checks if greetd is installed - greetd is a daemon in /usr/sbin on Debian/Ubuntu +func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error { + greetdFound := utils.CommandExists("greetd") + if !greetdFound { + for _, p := range []string{"/usr/sbin/greetd", "/sbin/greetd"} { + if _, err := os.Stat(p); err == nil { + greetdFound = true + break + } + } + } + if greetdFound { + logFunc("✓ greetd is already installed") + return nil + } + + logFunc("greetd is not installed. Installing...") + + osInfo, err := distros.GetOSInfo() + if err != nil { + return fmt.Errorf("failed to detect OS: %w", err) + } + + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return fmt.Errorf("unsupported distribution for automatic greetd installation: %s", osInfo.Distribution.ID) + } + + ctx := context.Background() + var installCmd *exec.Cmd + + switch config.Family { + case distros.FamilyArch: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "pacman -S --needed --noconfirm greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd") + } + + case distros.FamilyFedora: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "dnf install -y greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd") + } + + case distros.FamilySUSE: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "zypper install -y greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "greetd") + } + + case distros.FamilyUbuntu: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "apt-get install -y greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd") + } + + case distros.FamilyDebian: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "apt-get install -y greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd") + } + + case distros.FamilyGentoo: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "emerge --ask n sys-apps/greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-apps/greetd") + } + + case distros.FamilyNix: + return fmt.Errorf("on NixOS, please add greetd to your configuration.nix") + + default: + return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family) + } + + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install greetd: %w", err) + } + + logFunc("✓ greetd installed successfully") + return nil +} + +// IsGreeterPackaged returns true if dms-greeter was installed from a system package. +func IsGreeterPackaged() bool { + if !utils.CommandExists("dms-greeter") { + return false + } + packagedPath := "/usr/share/quickshell/dms-greeter" + info, err := os.Stat(packagedPath) + return err == nil && info.IsDir() +} + +// HasLegacyLocalGreeterWrapper returns true when a manually installed wrapper exists. +func HasLegacyLocalGreeterWrapper() bool { + info, err := os.Stat("/usr/local/bin/dms-greeter") + return err == nil && !info.IsDir() +} + +// TryInstallGreeterPackage attempts to install dms-greeter from the distro's official repo. +func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool { + osInfo, err := distros.GetOSInfo() + if err != nil { + return false + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return false + } + + if IsGreeterPackaged() { + logFunc("✓ dms-greeter package already installed") + return true + } + + ctx := context.Background() + var installCmd *exec.Cmd + var failHint string + + switch config.Family { + case distros.FamilyDebian: + obsSlug := getDebianOBSSlug(osInfo) + keyURL := fmt.Sprintf("https://download.opensuse.org/repositories/home:AvengeMedia:danklinux/%s/Release.key", obsSlug) + repoLine := fmt.Sprintf("deb [signed-by=/etc/apt/keyrings/danklinux.gpg] https://download.opensuse.org/repositories/home:/AvengeMedia:/danklinux/%s/ /", obsSlug) + failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo apt-get install -y gnupg\nsudo mkdir -p /etc/apt/keyrings\ncurl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg\necho '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list\nsudo apt update && sudo apt-get install -y dms-greeter", keyURL, repoLine) + logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug)) + if _, err := exec.LookPath("gpg"); err != nil { + logFunc("Installing gnupg for OBS repository key import...") + installGPGCmd := exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "gnupg") + installGPGCmd.Stdout = os.Stdout + installGPGCmd.Stderr = os.Stderr + if err := installGPGCmd.Run(); err != nil { + logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err)) + } + } + mkdirCmd := exec.CommandContext(ctx, "sudo", "mkdir", "-p", "/etc/apt/keyrings") + mkdirCmd.Stdout = os.Stdout + mkdirCmd.Stderr = os.Stderr + mkdirCmd.Run() + addKeyCmd := exec.CommandContext(ctx, "bash", "-c", + fmt.Sprintf(`curl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg`, keyURL)) + addKeyCmd.Stdout = os.Stdout + addKeyCmd.Stderr = os.Stderr + addKeyCmd.Run() + addRepoCmd := exec.CommandContext(ctx, "bash", "-c", + fmt.Sprintf(`echo '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list`, repoLine)) + addRepoCmd.Stdout = os.Stdout + addRepoCmd.Stderr = os.Stderr + addRepoCmd.Run() + exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") + case distros.FamilySUSE: + repoURL := getOpenSUSEOBSRepoURL(osInfo) + failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL) + logFunc("Adding DankLinux OBS repository...") + addRepoCmd := exec.CommandContext(ctx, "sudo", "zypper", "addrepo", repoURL) + addRepoCmd.Stdout = os.Stdout + addRepoCmd.Stderr = os.Stderr + addRepoCmd.Run() + exec.CommandContext(ctx, "sudo", "zypper", "refresh").Run() + installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "dms-greeter") + case distros.FamilyUbuntu: + failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter" + logFunc("Enabling PPA ppa:avengemedia/danklinux...") + ppacmd := exec.CommandContext(ctx, "sudo", "add-apt-repository", "-y", "ppa:avengemedia/danklinux") + ppacmd.Stdout = os.Stdout + ppacmd.Stderr = os.Stderr + ppacmd.Run() + exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") + case distros.FamilyFedora: + failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter" + logFunc("Enabling COPR avengemedia/danklinux...") + coprcmd := exec.CommandContext(ctx, "sudo", "dnf", "copr", "enable", "-y", "avengemedia/danklinux") + coprcmd.Stdout = os.Stdout + coprcmd.Stderr = os.Stderr + coprcmd.Run() + installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "dms-greeter") + case distros.FamilyArch: + aurHelper := "" + for _, helper := range []string{"paru", "yay"} { + if _, err := exec.LookPath(helper); err == nil { + aurHelper = helper + break + } + } + if aurHelper == "" { + logFunc("⚠ No AUR helper found (paru/yay). Install greetd-dms-greeter-git from AUR: https://aur.archlinux.org/packages/greetd-dms-greeter-git") + return false + } + failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper) + installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git") + default: + return false + } + + logFunc("Installing dms-greeter from official repository...") + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + + if err := installCmd.Run(); err != nil { + logFunc(failHint) + return false + } + + logFunc("✓ dms-greeter package installed") + return true +} + +// CopyGreeterFiles installs the dms-greeter wrapper and sets up cache directory +func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { + if IsGreeterPackaged() { + logFunc("✓ dms-greeter package already installed") + } else { + if dmsPath == "" { + return fmt.Errorf("dms path is required for manual dms-greeter wrapper installs") + } + + assetsDir := filepath.Join(dmsPath, "Modules", "Greetd", "assets") + wrapperSrc := filepath.Join(assetsDir, "dms-greeter") + + if _, err := os.Stat(wrapperSrc); os.IsNotExist(err) { + return fmt.Errorf("dms-greeter wrapper not found at %s", wrapperSrc) + } + + wrapperDst := "/usr/local/bin/dms-greeter" + action := "Installed" + if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() { + action = "Updated" + } + if err := runSudoCmd(sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil { + return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err) + } + logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst)) + + if err := runSudoCmd(sudoPassword, "chmod", "+x", wrapperDst); err != nil { + return fmt.Errorf("failed to make wrapper executable: %w", err) + } + + osInfo, err := distros.GetOSInfo() + if err == nil { + if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) { + if err := runSudoCmd(sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err)) + } else { + logFunc("✓ Set SELinux fcontext for dms-greeter") + } + + if err := runSudoCmd(sudoPassword, "restorecon", "-v", wrapperDst); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err)) + } else { + logFunc("✓ Restored SELinux context for dms-greeter") + } + } + } + } + + if err := EnsureGreeterCacheDir(logFunc, sudoPassword); err != nil { + return err + } + + return nil +} + +// EnsureGreeterCacheDir creates /var/cache/dms-greeter with correct ownership if it does not exist. +// 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 + 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() + daemonUser := DetectGreeterUser() + preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group) + owner := preferredOwner + if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil { + // 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", "2770", cacheDir); err != nil { + return fmt.Errorf("failed to set cache directory permissions: %w", err) + } + + 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 +} + +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 !IsAppArmorEnabled() { + return nil + } + + if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil { + return fmt.Errorf("failed to create /etc/apparmor.d: %w", err) + } + + tmp, err := os.CreateTemp("", "dms-apparmor-*") + if err != nil { + return fmt.Errorf("failed to create temp file for AppArmor profile: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(appArmorProfileData); err != nil { + tmp.Close() + return fmt.Errorf("failed to write AppArmor profile: %w", err) + } + tmp.Close() + + if err := runSudoCmd(sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil { + return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err) + } + if err := runSudoCmd(sudoPassword, "chmod", "644", appArmorProfileDest); err != nil { + return fmt.Errorf("failed to set AppArmor profile permissions: %w", err) + } + + if utils.CommandExists("apparmor_parser") { + if err := runSudoCmd(sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil { + logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err)) + logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest) + } else { + logFunc(" ✓ AppArmor profile installed and loaded (complain mode)") + } + } else { + logFunc(" ✓ AppArmor profile installed at " + appArmorProfileDest) + logFunc(" apparmor_parser not found — profile will be loaded on next boot") + } + + return nil +} + +// UninstallAppArmorProfile removes the DMS AppArmor profile and reloads AppArmor. +// It is a no-op when AppArmor is not active or the profile does not exist. +func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error { + if IsNixOS() { + return nil + } + if _, err := os.Stat("/sys/module/apparmor"); os.IsNotExist(err) { + return nil + } + if _, err := os.Stat(appArmorProfileDest); os.IsNotExist(err) { + return nil + } + + if utils.CommandExists("apparmor_parser") { + _ = runSudoCmd(sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest) + } + if err := runSudoCmd(sudoPassword, "rm", "-f", appArmorProfileDest); err != nil { + return fmt.Errorf("failed to remove AppArmor profile: %w", err) + } + logFunc(" ✓ Removed DMS AppArmor profile") + return nil +} + +// EnsureACLInstalled installs the acl package (setfacl/getfacl) if not already present +func EnsureACLInstalled(logFunc func(string), sudoPassword string) error { + if utils.CommandExists("setfacl") { + return nil + } + + logFunc("setfacl not found – installing acl package...") + + osInfo, err := distros.GetOSInfo() + if err != nil { + return fmt.Errorf("failed to detect OS: %w", err) + } + + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return fmt.Errorf("unsupported distribution for automatic acl installation: %s", osInfo.Distribution.ID) + } + + ctx := context.Background() + var installCmd *exec.Cmd + + switch config.Family { + case distros.FamilyArch: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "acl") + } + + case distros.FamilyFedora: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "dnf install -y acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "acl") + } + + case distros.FamilySUSE: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "zypper install -y acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "acl") + } + + case distros.FamilyUbuntu: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl") + } + + case distros.FamilyDebian: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl") + } + + case distros.FamilyGentoo: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-fs/acl") + } + + case distros.FamilyNix: + return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix") + + default: + return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family) + } + + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install acl: %w", err) + } + + logFunc("✓ acl package installed") + return nil +} + +// SetupParentDirectoryACLs sets ACLs on parent directories to allow traversal +func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error { + if err := EnsureACLInstalled(logFunc, sudoPassword); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: could not install acl package: %v", err)) + logFunc(" ACL permissions will be skipped; theme sync may not work correctly.") + return nil + } + if !utils.CommandExists("setfacl") { + logFunc("⚠ Warning: setfacl still not available after install attempt; skipping ACL setup.") + return nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + parentDirs := []struct { + path string + desc string + }{ + {homeDir, "home directory"}, + {filepath.Join(homeDir, ".config"), ".config directory"}, + {filepath.Join(homeDir, ".local"), ".local directory"}, + {filepath.Join(homeDir, ".cache"), ".cache directory"}, + {filepath.Join(homeDir, ".local", "state"), ".local/state directory"}, + {filepath.Join(homeDir, ".local", "share"), ".local/share directory"}, + } + + group := DetectGreeterGroup() + + logFunc("\nSetting up parent directory ACLs for greeter user access...") + + for _, dir := range parentDirs { + if _, err := os.Stat(dir.path); os.IsNotExist(err) { + if err := os.MkdirAll(dir.path, 0o755); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.desc, err)) + continue + } + } + + // 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 g:%s:rX %s", group, dir.path)) + continue + } + + logFunc(fmt.Sprintf("✓ Set ACL on %s", dir.desc)) + } + + 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 { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + currentUser := os.Getenv("USER") + if currentUser == "" { + currentUser = os.Getenv("LOGNAME") + } + if currentUser == "" { + return fmt.Errorf("failed to determine current user") + } + + 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) { + logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group)) + } else { + if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, currentUser); err != nil { + return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err) + } + 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 + }{ + {filepath.Join(homeDir, ".config", "DankMaterialShell"), "DankMaterialShell config"}, + {filepath.Join(homeDir, ".local", "state", "DankMaterialShell"), "DankMaterialShell state"}, + {filepath.Join(homeDir, ".cache", "DankMaterialShell"), "DankMaterialShell cache"}, + {filepath.Join(homeDir, ".cache", "quickshell"), "quickshell cache"}, + {filepath.Join(homeDir, ".config", "quickshell"), "quickshell config"}, + {filepath.Join(homeDir, ".local", "share", "wayland-sessions"), "wayland sessions"}, + {filepath.Join(homeDir, ".local", "share", "xsessions"), "xsessions"}, + } + + for _, dir := range configDirs { + if _, err := os.Stat(dir.path); os.IsNotExist(err) { + if err := os.MkdirAll(dir.path, 0o755); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.path, err)) + continue + } + } + + if err := runSudoCmd(sudoPassword, "chgrp", "-R", group, dir.path); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err)) + continue + } + + if err := runSudoCmd(sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err)) + continue + } + + logFunc(fmt.Sprintf("✓ Set group permissions for %s", dir.desc)) + } + + if err := SetupParentDirectoryACLs(logFunc, sudoPassword); err != nil { + return fmt.Errorf("failed to setup parent directory ACLs: %w", err) + } + + return nil +} + +type GreeterColorSyncInfo struct { + SourcePath string + ThemeName string + UsesDynamicWallpaperOverride bool +} + +type greeterThemeSyncSettings struct { + CurrentThemeName string `json:"currentThemeName"` + GreeterWallpaperPath string `json:"greeterWallpaperPath"` + MatugenScheme string `json:"matugenScheme"` + IconTheme string `json:"iconTheme"` +} + +type greeterThemeSyncSession struct { + IsLightMode bool `json:"isLightMode"` +} + +type greeterThemeSyncState struct { + ThemeName string + GreeterWallpaperPath string + ResolvedGreeterWallpaperPath string + MatugenScheme string + IconTheme string + IsLightMode bool + UsesDynamicWallpaperOverride bool +} + +func defaultGreeterColorsSource(homeDir string) string { + return filepath.Join(homeDir, ".cache", "DankMaterialShell", "dms-colors.json") +} + +func greeterOverrideColorsStateDir(homeDir string) string { + return filepath.Join(homeDir, ".cache", "DankMaterialShell", "greeter-colors") +} + +func greeterOverrideColorsSource(homeDir string) string { + return filepath.Join(greeterOverrideColorsStateDir(homeDir), "dms-colors.json") +} + +func readOptionalJSONFile(path string, dst any) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if strings.TrimSpace(string(data)) == "" { + return nil + } + return json.Unmarshal(data, dst) +} + +func readGreeterThemeSyncSettings(homeDir string) (greeterThemeSyncSettings, error) { + settings := greeterThemeSyncSettings{ + CurrentThemeName: "purple", + MatugenScheme: "scheme-tonal-spot", + IconTheme: "System Default", + } + settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") + if err := readOptionalJSONFile(settingsPath, &settings); err != nil { + return greeterThemeSyncSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err) + } + return settings, nil +} + +func readGreeterThemeSyncSession(homeDir string) (greeterThemeSyncSession, error) { + session := greeterThemeSyncSession{} + sessionPath := filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json") + if err := readOptionalJSONFile(sessionPath, &session); err != nil { + return greeterThemeSyncSession{}, fmt.Errorf("failed to parse session at %s: %w", sessionPath, err) + } + return session, nil +} + +func resolveGreeterThemeSyncState(homeDir string) (greeterThemeSyncState, error) { + settings, err := readGreeterThemeSyncSettings(homeDir) + if err != nil { + return greeterThemeSyncState{}, err + } + session, err := readGreeterThemeSyncSession(homeDir) + if err != nil { + return greeterThemeSyncState{}, err + } + + resolvedWallpaperPath := "" + if settings.GreeterWallpaperPath != "" { + resolvedWallpaperPath = settings.GreeterWallpaperPath + if !filepath.IsAbs(resolvedWallpaperPath) { + resolvedWallpaperPath = filepath.Join(homeDir, resolvedWallpaperPath) + } + } + + usesDynamicWallpaperOverride := strings.EqualFold(strings.TrimSpace(settings.CurrentThemeName), "dynamic") && resolvedWallpaperPath != "" + + return greeterThemeSyncState{ + ThemeName: settings.CurrentThemeName, + GreeterWallpaperPath: settings.GreeterWallpaperPath, + ResolvedGreeterWallpaperPath: resolvedWallpaperPath, + MatugenScheme: settings.MatugenScheme, + IconTheme: settings.IconTheme, + IsLightMode: session.IsLightMode, + UsesDynamicWallpaperOverride: usesDynamicWallpaperOverride, + }, nil +} + +func (s greeterThemeSyncState) effectiveColorsSource(homeDir string) string { + if s.UsesDynamicWallpaperOverride { + return greeterOverrideColorsSource(homeDir) + } + return defaultGreeterColorsSource(homeDir) +} + +func ResolveGreeterColorSyncInfo(homeDir string) (GreeterColorSyncInfo, error) { + state, err := resolveGreeterThemeSyncState(homeDir) + if err != nil { + return GreeterColorSyncInfo{}, err + } + return GreeterColorSyncInfo{ + SourcePath: state.effectiveColorsSource(homeDir), + ThemeName: state.ThemeName, + UsesDynamicWallpaperOverride: state.UsesDynamicWallpaperOverride, + }, nil +} + +func ensureGreeterSyncSourceFile(path string) error { + sourceDir := filepath.Dir(path) + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + return fmt.Errorf("failed to create source directory %s: %w", sourceDir, err) + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { + return fmt.Errorf("failed to create source file %s: %w", path, err) + } + } else if err != nil { + return fmt.Errorf("failed to inspect source file %s: %w", path, err) + } + + return nil +} + +func syncGreeterDynamicOverrideColors(dmsPath, homeDir string, state greeterThemeSyncState, logFunc func(string)) error { + if !state.UsesDynamicWallpaperOverride { + return nil + } + + st, err := os.Stat(state.ResolvedGreeterWallpaperPath) + if err != nil { + return fmt.Errorf("configured greeter wallpaper not found at %s: %w", state.ResolvedGreeterWallpaperPath, err) + } + if st.IsDir() { + return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", state.ResolvedGreeterWallpaperPath) + } + + mode := matugen.ColorModeDark + if state.IsLightMode { + mode = matugen.ColorModeLight + } + + opts := matugen.Options{ + StateDir: greeterOverrideColorsStateDir(homeDir), + ShellDir: dmsPath, + ConfigDir: filepath.Join(homeDir, ".config"), + Kind: "image", + Value: state.ResolvedGreeterWallpaperPath, + Mode: mode, + IconTheme: state.IconTheme, + MatugenType: state.MatugenScheme, + RunUserTemplates: false, + ColorsOnly: true, + } + + err = matugen.Run(opts) + switch { + case errors.Is(err, matugen.ErrNoChanges): + logFunc("✓ Greeter dynamic override colors already up to date") + return nil + case err != nil: + return fmt.Errorf("failed to generate greeter dynamic colors from wallpaper override: %w", err) + default: + logFunc("✓ Generated greeter dynamic colors from wallpaper override") + return nil + } +} + +func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncState, logFunc func(string), sudoPassword string) error { + source := state.effectiveColorsSource(homeDir) + if !state.UsesDynamicWallpaperOverride { + if err := ensureGreeterSyncSourceFile(source); err != nil { + return err + } + } else if _, err := os.Stat(source); err != nil { + return fmt.Errorf("expected generated greeter colors at %s: %w", source, err) + } + + target := filepath.Join(cacheDir, "colors.json") + _ = runSudoCmd(sudoPassword, "rm", "-f", target) + if err := runSudoCmd(sudoPassword, "ln", "-sf", source, target); err != nil { + return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err) + } + + if state.UsesDynamicWallpaperOverride { + logFunc("✓ Synced wallpaper based theming (greeter wallpaper override)") + } else { + logFunc("✓ Synced wallpaper based theming") + } + + return nil +} + +func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + cacheDir := GreeterCacheDir + + symlinks := []struct { + source string + target string + desc string + }{ + { + source: filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), + target: filepath.Join(cacheDir, "settings.json"), + desc: "core settings (theme, clock formats, etc)", + }, + { + source: filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), + target: filepath.Join(cacheDir, "session.json"), + desc: "state (wallpaper configuration)", + }, + } + + for _, link := range symlinks { + sourceDir := filepath.Dir(link.source) + if _, err := os.Stat(sourceDir); os.IsNotExist(err) { + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + return fmt.Errorf("failed to create source directory %s for %s: %w", sourceDir, link.desc, err) + } + } + + if _, err := os.Stat(link.source); os.IsNotExist(err) { + if err := os.WriteFile(link.source, []byte("{}"), 0o644); err != nil { + return fmt.Errorf("failed to create source file %s for %s: %w", link.source, link.desc, err) + } + } + + _ = runSudoCmd(sudoPassword, "rm", "-f", link.target) + + if err := runSudoCmd(sudoPassword, "ln", "-sf", link.source, link.target); err != nil { + return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err) + } + + logFunc(fmt.Sprintf("✓ Synced %s", link.desc)) + } + + state, err := resolveGreeterThemeSyncState(homeDir) + if err != nil { + return fmt.Errorf("failed to resolve greeter color source: %w", err) + } + + if err := syncGreeterDynamicOverrideColors(dmsPath, homeDir, state, logFunc); err != nil { + return err + } + + if err := syncGreeterColorSource(homeDir, cacheDir, state, logFunc, sudoPassword); err != nil { + return err + } + + if err := syncGreeterWallpaperOverride(cacheDir, logFunc, sudoPassword, state); err != nil { + return fmt.Errorf("greeter wallpaper override sync failed: %w", err) + } + + if strings.ToLower(compositor) != "niri" { + return nil + } + + if err := syncNiriGreeterConfig(logFunc, sudoPassword); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to sync niri greeter config: %v", err)) + } + + return nil +} + +func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error { + destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg") + if state.ResolvedGreeterWallpaperPath == "" { + if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { + return fmt.Errorf("failed to clear override file %s: %w", destPath, err) + } + logFunc("✓ Cleared greeter wallpaper override") + return nil + } + if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { + return fmt.Errorf("failed to remove old override file %s: %w", destPath, err) + } + src := state.ResolvedGreeterWallpaperPath + st, err := os.Stat(src) + if err != nil { + return fmt.Errorf("configured greeter wallpaper not found at %s: %w", src, err) + } + if st.IsDir() { + return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src) + } + if err := runSudoCmd(sudoPassword, "cp", src, destPath); err != nil { + return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err) + } + greeterGroup := DetectGreeterGroup() + 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) + } + logFunc("✓ Synced greeter wallpaper override") + return nil +} + +type niriGreeterSync struct { + processed map[string]bool + nodes []*document.Node + inputCount int + outputCount int + cursorCount int + debugCount int + cursorNode *document.Node + inputNode *document.Node + outputNodes map[string]*document.Node +} + +func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error { + configDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("failed to resolve user config directory: %w", err) + } + + configPath := filepath.Join(configDir, "niri", "config.kdl") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + logFunc("ℹ Niri config not found; skipping greeter niri sync") + return nil + } else if err != nil { + return fmt.Errorf("failed to stat niri config: %w", err) + } + + extractor := &niriGreeterSync{ + processed: make(map[string]bool), + outputNodes: make(map[string]*document.Node), + } + + if err := extractor.processFile(configPath); err != nil { + return err + } + + if len(extractor.nodes) == 0 { + logFunc("ℹ No niri input/output sections found; skipping greeter niri sync") + return nil + } + + content := extractor.render() + if strings.TrimSpace(content) == "" { + logFunc("ℹ No niri input/output content to sync; skipping greeter niri sync") + return nil + } + + greeterDir := "/etc/greetd/niri" + greeterGroup := DetectGreeterGroup() + if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil { + return fmt.Errorf("failed to create greetd niri directory: %w", err) + } + if err := runSudoCmd(sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil { + return fmt.Errorf("failed to set greetd niri directory ownership: %w", err) + } + if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil { + return fmt.Errorf("failed to set greetd niri directory permissions: %w", err) + } + + dmsTemp, err := os.CreateTemp("", "dms-greeter-niri-dms-*.kdl") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(dmsTemp.Name()) + + if _, err := dmsTemp.WriteString(content); err != nil { + _ = dmsTemp.Close() + return fmt.Errorf("failed to write temp niri config: %w", err) + } + if err := dmsTemp.Close(); err != nil { + return fmt.Errorf("failed to close temp niri config: %w", err) + } + + dmsPath := filepath.Join(greeterDir, "dms.kdl") + if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil { + return fmt.Errorf("failed to backup %s: %w", dmsPath, err) + } + if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil { + return fmt.Errorf("failed to install greetd niri dms config: %w", err) + } + + mainContent := fmt.Sprintf("%s\ninclude \"%s\"\n", config.NiriGreeterConfig, dmsPath) + mainTemp, err := os.CreateTemp("", "dms-greeter-niri-main-*.kdl") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(mainTemp.Name()) + + if _, err := mainTemp.WriteString(mainContent); err != nil { + _ = mainTemp.Close() + return fmt.Errorf("failed to write temp niri main config: %w", err) + } + if err := mainTemp.Close(); err != nil { + return fmt.Errorf("failed to close temp niri main config: %w", err) + } + + mainPath := filepath.Join(greeterDir, "config.kdl") + if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil { + return fmt.Errorf("failed to backup %s: %w", mainPath, err) + } + if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil { + return fmt.Errorf("failed to install greetd niri main config: %w", err) + } + + if err := ensureGreetdNiriConfig(logFunc, sudoPassword, mainPath); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to update greetd config for niri: %v", err)) + } + + logFunc(fmt.Sprintf("✓ Synced niri greeter config (%d input, %d output, %d cursor, %d debug) to %s", extractor.inputCount, extractor.outputCount, extractor.cursorCount, extractor.debugCount, dmsPath)) + return nil +} + +func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfigPath string) error { + configPath := "/etc/greetd/config.toml" + data, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + logFunc("ℹ greetd config not found; skipping niri config wiring") + return nil + } + if err != nil { + return fmt.Errorf("failed to read greetd config: %w", err) + } + + lines := strings.Split(string(data), "\n") + updated := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "command") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + continue + } + + command := strings.Trim(strings.TrimSpace(parts[1]), "\"") + if !strings.Contains(command, "dms-greeter") { + continue + } + if !strings.Contains(command, "--command niri") { + continue + } + command = stripConfigFlag(command) + command = stripCacheDirFlag(command) + command = strings.TrimSpace(command + " --cache-dir " + GreeterCacheDir) + + newCommand := fmt.Sprintf("%s -C %s", command, niriConfigPath) + idx := strings.Index(line, "command") + leading := "" + if idx > 0 { + leading = line[:idx] + } + lines[i] = fmt.Sprintf("%scommand = \"%s\"", leading, newCommand) + updated = true + break + } + + if !updated { + return nil + } + + tmpFile, err := os.CreateTemp("", "greetd-config-*.toml") + if err != nil { + return fmt.Errorf("failed to create temp greetd config: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(strings.Join(lines, "\n")); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write temp greetd config: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp greetd config: %w", err) + } + + if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil { + return fmt.Errorf("failed to update greetd config: %w", err) + } + + logFunc(fmt.Sprintf("✓ Updated greetd config to use niri config %s", niriConfigPath)) + return nil +} + +func backupFileIfExists(sudoPassword string, path string, suffix string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + + backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405")) + if err := runSudoCmd(sudoPassword, "cp", path, backupPath); err != nil { + return err + } + return runSudoCmd(sudoPassword, "chmod", "644", backupPath) +} + +func (s *niriGreeterSync) processFile(filePath string) error { + absPath, err := filepath.Abs(filePath) + if err != nil { + return fmt.Errorf("failed to resolve path %s: %w", filePath, err) + } + + if s.processed[absPath] { + return nil + } + s.processed[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", absPath, err) + } + + doc, err := kdl.Parse(strings.NewReader(string(data))) + if err != nil { + return fmt.Errorf("failed to parse KDL in %s: %w", absPath, err) + } + + baseDir := filepath.Dir(absPath) + for _, node := range doc.Nodes { + name := node.Name.String() + switch name { + case "include": + if err := s.handleInclude(node, baseDir); err != nil { + return err + } + case "input": + 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": + 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 { + s.cursorNode = node + s.cursorNode.Children = dedupeCursorChildren(s.cursorNode.Children) + s.nodes = append(s.nodes, node) + s.cursorCount++ + } else if len(node.Children) > 0 { + s.cursorNode.Children = mergeCursorChildren(s.cursorNode.Children, node.Children) + } + case "debug": + s.nodes = append(s.nodes, node) + s.debugCount++ + } + } + + return nil +} + +func mergeCursorChildren(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 dedupeCursorChildren(children []*document.Node) []*document.Node { + if len(children) == 0 { + return children + } + + var result []*document.Node + indexByName := make(map[string]int, len(children)) + for _, child := range children { + name := child.Name.String() + if idx, ok := indexByName[name]; ok { + result[idx] = child + continue + } + indexByName[name] = len(result) + result = append(result, child) + } + + 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 + } + + includePath := strings.Trim(node.Arguments[0].String(), "\"") + if includePath == "" { + return nil + } + + fullPath := includePath + if !filepath.IsAbs(includePath) { + fullPath = filepath.Join(baseDir, includePath) + } + + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + return nil + } else if err != nil { + return fmt.Errorf("failed to stat include %s: %w", fullPath, err) + } + + return s.processFile(fullPath) +} + +func (s *niriGreeterSync) render() string { + if len(s.nodes) == 0 { + return "" + } + + var builder strings.Builder + for _, node := range s.nodes { + _, _ = node.WriteToOptions(&builder, document.NodeWriteOptions{ + LeadingTrailingSpace: true, + NameAndType: true, + Depth: 0, + Indent: []byte(" "), + IgnoreFlags: false, + }) + builder.WriteString("\n") + } + + return builder.String() +} + +func ConfigureGreetd(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { + configPath := "/etc/greetd/config.toml" + + backupPath := fmt.Sprintf("%s.backup-%s", configPath, time.Now().Format("20060102-150405")) + if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil { + return fmt.Errorf("failed to backup greetd config: %w", err) + } + if _, err := os.Stat(configPath); err == nil { + logFunc(fmt.Sprintf("✓ Backed up existing config to %s", backupPath)) + } + + greeterUser := DetectGreeterUser() + + var configContent string + if data, err := os.ReadFile(configPath); err == nil { + configContent = string(data) + } else if os.IsNotExist(err) { + configContent = `[terminal] +vt = 1 + +[default_session] +` + } else { + return fmt.Errorf("failed to read greetd config: %w", err) + } + + wrapperCmd := resolveGreeterWrapperPath() + + compositorLower := strings.ToLower(compositor) + commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir) + if dmsPath != "" { + commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath) + } + + commandLine := fmt.Sprintf(`command = "%s"`, commandValue) + newConfig := upsertDefaultSession(configContent, greeterUser, commandLine) + + tmpFile, err := os.CreateTemp("", "greetd-config-*.toml") + if err != nil { + return fmt.Errorf("failed to create temp greetd config: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(newConfig); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write temp greetd config: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp greetd config: %w", err) + } + + if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil { + return fmt.Errorf("failed to create /etc/greetd: %w", err) + } + + if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil { + return fmt.Errorf("failed to install greetd config: %w", err) + } + + logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue)) + return nil +} + +func stripConfigFlag(command string) string { + for _, flag := range []string{" -C ", " --config "} { + idx := strings.Index(command, flag) + if idx == -1 { + continue + } + + before := command[:idx] + after := command[idx+len(flag):] + + switch { + case strings.HasPrefix(after, `"`): + if end := strings.Index(after[1:], `"`); end != -1 { + after = after[end+2:] + } else { + after = "" + } + default: + if space := strings.Index(after, " "); space != -1 { + after = after[space:] + } else { + after = "" + } + } + + command = strings.TrimSpace(before + after) + } + + return command +} + +func stripCacheDirFlag(command string) string { + fields := strings.Fields(command) + if len(fields) == 0 { + return strings.TrimSpace(command) + } + + filtered := make([]string, 0, len(fields)) + for i := 0; i < len(fields); i++ { + token := fields[i] + if token == "--cache-dir" { + if i+1 < len(fields) { + i++ + } + continue + } + if strings.HasPrefix(token, "--cache-dir=") { + continue + } + filtered = append(filtered, token) + } + + return strings.Join(filtered, " ") +} + +// getDebianOBSSlug returns the OBS repository slug for the running Debian version. +func getDebianOBSSlug(osInfo *distros.OSInfo) string { + versionID := strings.ToLower(osInfo.VersionID) + codename := strings.ToLower(osInfo.VersionCodename) + prettyName := strings.ToLower(osInfo.PrettyName) + + if strings.Contains(prettyName, "sid") || strings.Contains(prettyName, "unstable") || + codename == "sid" || versionID == "sid" { + return "Debian_Unstable" + } + if versionID == "testing" || codename == "testing" { + return "Debian_Testing" + } + if versionID != "" { + return "Debian_" + versionID // "Debian_13" + } + return "Debian_Unstable" +} + +// getOpenSUSEOBSRepoURL returns the OBS .repo file URL for the running openSUSE variant. +func getOpenSUSEOBSRepoURL(osInfo *distros.OSInfo) string { + const base = "https://download.opensuse.org/repositories/home:AvengeMedia:danklinux" + var slug string + switch osInfo.Distribution.ID { + case "opensuse-leap": + v := osInfo.VersionID + if v != "" && !strings.Contains(v, ".") { + v += ".0" // "16" → "16.0" + } + if v == "" { + v = "16.0" + } + slug = v + case "opensuse-slowroll": + slug = "openSUSE_Slowroll" + default: // opensuse-tumbleweed || unknown version + slug = "openSUSE_Tumbleweed" + } + return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug) +} + +func runSudoCmd(sudoPassword string, command string, args ...string) error { + var cmd *exec.Cmd + + if sudoPassword != "" { + fullArgs := append([]string{command}, args...) + quotedArgs := make([]string, len(fullArgs)) + for i, arg := range fullArgs { + quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" + } + cmdStr := strings.Join(quotedArgs, " ") + + cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr) + } else { + cmd = exec.Command("sudo", append([]string{command}, args...)...) + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func checkSystemdEnabled(service string) (string, error) { + cmd := exec.Command("systemctl", "is-enabled", service) + output, _ := cmd.Output() + return strings.TrimSpace(string(output)), nil +} + +func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)) error { + conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} + for _, dm := range conflictingDMs { + state, err := checkSystemdEnabled(dm) + if err != nil || state == "" || state == "not-found" { + continue + } + switch state { + case "enabled", "enabled-runtime", "static", "indirect", "alias": + logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm)) + if err := runSudoCmd(sudoPassword, "systemctl", "disable", dm); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err)) + } else { + logFunc(fmt.Sprintf("✓ Disabled %s", dm)) + } + } + } + return nil +} + +// EnableGreetd unmasks and enables greetd, forcing it over any other DM. +func EnableGreetd(sudoPassword string, logFunc func(string)) error { + state, err := checkSystemdEnabled("greetd") + if err != nil { + return fmt.Errorf("failed to check greetd state: %w", err) + } + if state == "not-found" { + return fmt.Errorf("greetd service not found; ensure greetd is installed") + } + if state == "masked" || state == "masked-runtime" { + logFunc(" Unmasking greetd...") + if err := runSudoCmd(sudoPassword, "systemctl", "unmask", "greetd"); err != nil { + return fmt.Errorf("failed to unmask greetd: %w", err) + } + logFunc(" ✓ Unmasked greetd") + } + logFunc(" Enabling greetd service (--force)...") + if err := runSudoCmd(sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil { + return fmt.Errorf("failed to enable greetd: %w", err) + } + logFunc("✓ greetd enabled") + return nil +} + +func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error { + cmd := exec.Command("systemctl", "get-default") + output, err := cmd.Output() + if err != nil { + logFunc(fmt.Sprintf("⚠ Warning: could not get default systemd target: %v", err)) + return nil + } + current := strings.TrimSpace(string(output)) + if current == "graphical.target" { + logFunc("✓ Default target is already graphical.target") + return nil + } + logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current)) + if err := runSudoCmd(sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil { + return fmt.Errorf("failed to set graphical target: %w", err) + } + logFunc("✓ Default target set to graphical.target") + return nil +} + +// AutoSetupGreeter performs the full non-interactive greeter setup +func AutoSetupGreeter(compositor, sudoPassword string, logFunc func(string)) error { + if IsGreeterPackaged() && 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") + } + + logFunc("Ensuring greetd is installed...") + if err := EnsureGreetdInstalled(logFunc, sudoPassword); err != nil { + return fmt.Errorf("greetd install failed: %w", err) + } + + dmsPath := "" + if !IsGreeterPackaged() { + detected, err := DetectDMSPath() + if err != nil { + return fmt.Errorf("DMS installation not found: %w", err) + } + dmsPath = detected + logFunc(fmt.Sprintf("✓ Found DMS at: %s", dmsPath)) + } else { + logFunc("✓ Using packaged dms-greeter (/usr/share/quickshell/dms-greeter)") + } + + logFunc("Setting up dms-greeter group and permissions...") + if err := SetupDMSGroup(logFunc, sudoPassword); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: group/permissions setup error: %v", err)) + } + + logFunc("Copying greeter files...") + if err := CopyGreeterFiles(dmsPath, compositor, logFunc, sudoPassword); err != nil { + return fmt.Errorf("failed to copy greeter files: %w", err) + } + + logFunc("Configuring greetd...") + greeterPathForConfig := "" + if !IsGreeterPackaged() { + greeterPathForConfig = dmsPath + } + if err := ConfigureGreetd(greeterPathForConfig, compositor, logFunc, sudoPassword); err != nil { + return fmt.Errorf("failed to configure greetd: %w", err) + } + + logFunc("Synchronizing DMS configurations...") + if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: config sync error: %v", err)) + } + + logFunc("Configuring authentication...") + if err := sharedpam.SyncAuthConfig(logFunc, sudoPassword, sharedpam.SyncAuthOptions{}); err != nil { + return fmt.Errorf("failed to sync authentication: %w", err) + } + + logFunc("Checking for conflicting display managers...") + if err := DisableConflictingDisplayManagers(sudoPassword, logFunc); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: %v", err)) + } + + logFunc("Enabling greetd service...") + if err := EnableGreetd(sudoPassword, logFunc); err != nil { + return fmt.Errorf("failed to enable greetd: %w", err) + } + + logFunc("Ensuring graphical.target as default...") + if err := EnsureGraphicalTarget(sudoPassword, logFunc); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: %v", err)) + } + + logFunc("✓ DMS greeter setup complete") + return nil +} -- cgit v1.3