diff --git a/core/cmd/dms/commands_greeter.go b/core/cmd/dms/commands_greeter.go index abcd9854..4377dde6 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -39,12 +39,29 @@ var greeterSyncCmd = &cobra.Command{ Short: "Sync DMS theme and settings with greeter", Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen", Run: func(cmd *cobra.Command, args []string) { - if err := syncGreeter(); err != nil { + yes, _ := cmd.Flags().GetBool("yes") + auth, _ := cmd.Flags().GetBool("auth") + local, _ := cmd.Flags().GetBool("local") + term, _ := cmd.Flags().GetBool("terminal") + if term { + if err := syncInTerminal(yes, auth, local); err != nil { + log.Fatalf("Error launching sync in terminal: %v", err) + } + return + } + if err := syncGreeter(yes, auth, local); err != nil { log.Fatalf("Error syncing greeter: %v", err) } }, } +func init() { + greeterSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts, use defaults (for UI)") + greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done") + greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles") + greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path") +} + var greeterEnableCmd = &cobra.Command{ Use: "enable", Short: "Enable DMS greeter in greetd config", @@ -147,7 +164,7 @@ func installGreeter() error { } fmt.Println("\nSynchronizing DMS configurations...") - if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, ""); err != nil { + if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, "", false); err != nil { return err } @@ -171,22 +188,88 @@ func installGreeter() error { return nil } -func syncGreeter() error { - fmt.Println("=== DMS Greeter Theme Sync ===") - fmt.Println() +func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error { + syncFlags := make([]string, 0, 3) + if nonInteractive { + syncFlags = append(syncFlags, "--yes") + } + if forceAuth { + syncFlags = append(syncFlags, "--auth") + } + if local { + syncFlags = append(syncFlags, "--local") + } + shellSyncCmd := "dms greeter sync" + if len(syncFlags) > 0 { + shellSyncCmd += " " + strings.Join(syncFlags, " ") + } + shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3` + terminals := []struct { + name string + args []string + }{ + {"gnome-terminal", []string{"--", "bash", "-c", shellCmd}}, + {"konsole", []string{"-e", "bash", "-c", shellCmd}}, + {"xfce4-terminal", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}}, + {"ghostty", []string{"-e", "bash", "-c", shellCmd}}, + {"wezterm", []string{"start", "--", "bash", "-c", shellCmd}}, + {"alacritty", []string{"-e", "bash", "-c", shellCmd}}, + {"kitty", []string{"bash", "-c", shellCmd}}, + {"xterm", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}}, + } + for _, t := range terminals { + if _, err := exec.LookPath(t.name); err != nil { + continue + } + cmd := exec.Command(t.name, t.args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + continue + } + _ = cmd.Process.Release() + return nil + } + return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)") +} + +func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { + if !nonInteractive { + fmt.Println("=== DMS Greeter Theme Sync ===") + fmt.Println() + } logFunc := func(msg string) { fmt.Println(msg) } - fmt.Println("Detecting DMS installation...") - dmsPath, err := greeter.DetectDMSPath() - if err != nil { - return err + if !nonInteractive { + fmt.Println("Detecting DMS installation...") + } + var dmsPath string + var err error + if local { + dmsPath, err = resolveLocalDMSPath() + if err != nil { + return err + } + if !nonInteractive { + fmt.Printf("✓ Using local DMS path: %s\n", dmsPath) + } + } else { + dmsPath, err = greeter.DetectDMSPath() + if err != nil { + return err + } + if !nonInteractive { + fmt.Printf("✓ Found DMS at: %s\n", dmsPath) + } } - fmt.Printf("✓ Found DMS at: %s\n", dmsPath) if !isGreeterEnabled() { + if nonInteractive { + return fmt.Errorf("greeter is not enabled; run 'dms greeter install' or 'dms greeter enable' first") + } fmt.Println("\n⚠ DMS greeter is not enabled in greetd config.") fmt.Print("Would you like to enable it now? (Y/n): ") @@ -203,9 +286,12 @@ func syncGreeter() error { } } - cacheDir := "/var/cache/dms-greeter" + cacheDir := greeter.GreeterCacheDir if _, err := os.Stat(cacheDir); os.IsNotExist(err) { - return fmt.Errorf("greeter cache directory not found at %s\nPlease install the greeter first", cacheDir) + 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() @@ -224,6 +310,9 @@ func syncGreeter() 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) @@ -255,8 +344,14 @@ func syncGreeter() error { return fmt.Errorf("no supported compositors found") case 1: compositor = compositors[0] - fmt.Printf("✓ Using compositor: %s\n", compositor) + if !nonInteractive { + fmt.Printf("✓ Using compositor: %s\n", compositor) + } default: + if nonInteractive { + compositor = compositors[0] + break + } var err error compositor, err = promptCompositorChoice(compositors) if err != nil { @@ -264,27 +359,159 @@ func syncGreeter() error { } fmt.Printf("✓ Selected compositor: %s\n", compositor) } - } else { + } else if !nonInteractive { fmt.Printf("✓ Detected compositor from config: %s\n", compositor) } + if local { + localWrapperScript := filepath.Join(dmsPath, "Modules", "Greetd", "assets", "dms-greeter") + restoreWrapperOverride := func() {} + if info, statErr := os.Stat(localWrapperScript); statErr == nil && !info.IsDir() { + previousWrapperOverride, hadWrapperOverride := os.LookupEnv("DMS_GREETER_WRAPPER_CMD") + wrapperCmdOverride := "/usr/bin/bash " + localWrapperScript + _ = os.Setenv("DMS_GREETER_WRAPPER_CMD", wrapperCmdOverride) + restoreWrapperOverride = func() { + if hadWrapperOverride { + _ = os.Setenv("DMS_GREETER_WRAPPER_CMD", previousWrapperOverride) + } else { + _ = os.Unsetenv("DMS_GREETER_WRAPPER_CMD") + } + } + if !nonInteractive { + fmt.Printf("✓ Using local greeter wrapper script: %s\n", localWrapperScript) + } + } else if !nonInteractive { + fmt.Printf("ℹ Local wrapper script not found at %s; using system wrapper.\n", localWrapperScript) + } + + fmt.Println("\nUpdating greetd command to use local DMS path...") + err := greeter.ConfigureGreetd(dmsPath, compositor, logFunc, "") + restoreWrapperOverride() + if err != nil { + return fmt.Errorf("failed to apply local greeter path: %w", err) + } + if !nonInteractive { + fmt.Println("ℹ Local mode applies both DMS path override (-p) and local wrapper behavior when available.") + } + } else { + greeterPathForConfig := "" + if !greeter.IsGreeterPackaged() { + greeterPathForConfig = dmsPath + } + fmt.Println("\nUpdating greetd command...") + if err := greeter.ConfigureGreetd(greeterPathForConfig, compositor, logFunc, ""); err != nil { + return fmt.Errorf("failed to update greetd command: %w", err) + } + } + fmt.Println("\nSetting up permissions and ACLs...") if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { return err } fmt.Println("\nSynchronizing DMS configurations...") - if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, ""); err != nil { + if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, "", forceAuth); err != nil { return err } fmt.Println("\n=== Sync Complete ===") fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.") + if forceAuth { + fmt.Println("PAM has been configured for fingerprint and U2F (where modules exist).") + } fmt.Println("The changes will be visible on the next login screen.") return nil } +func hasDmsShellQml(dir string) bool { + info, err := os.Stat(filepath.Join(dir, "shell.qml")) + return err == nil && !info.IsDir() +} + +func resolveDMSLocalCandidate(path string) (string, bool) { + if path == "" { + return "", false + } + if hasDmsShellQml(path) { + abs, err := filepath.Abs(path) + if err != nil { + return path, true + } + return abs, true + } + + quickshellPath := filepath.Join(path, "quickshell") + if hasDmsShellQml(quickshellPath) { + abs, err := filepath.Abs(quickshellPath) + if err != nil { + return quickshellPath, true + } + return abs, true + } + + return "", false +} + +func resolveLocalDMSPath() (string, error) { + if override := strings.TrimSpace(os.Getenv("DMS_LOCAL_PATH")); override != "" { + if resolved, ok := resolveDMSLocalCandidate(override); ok { + return resolved, nil + } + return "", fmt.Errorf("DMS_LOCAL_PATH is set but does not point to a valid DMS quickshell path: %s", override) + } + + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + dir := wd + for { + if resolved, ok := resolveDMSLocalCandidate(dir); ok { + return resolved, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + homeDir, err := os.UserHomeDir() + if err == nil && homeDir != "" { + for _, candidate := range []string{ + filepath.Join(homeDir, "dms"), + filepath.Join(homeDir, "DankMaterialShell"), + filepath.Join(homeDir, "dankmaterialshell"), + filepath.Join(homeDir, "projects", "dms"), + filepath.Join(homeDir, "src", "dms"), + } { + if resolved, ok := resolveDMSLocalCandidate(candidate); ok { + return resolved, nil + } + } + + if entries, readErr := os.ReadDir(homeDir); readErr == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := strings.ToLower(entry.Name()) + if !strings.Contains(name, "dms") && !strings.Contains(name, "dank") { + continue + } + if resolved, ok := resolveDMSLocalCandidate(filepath.Join(homeDir, entry.Name())); ok { + return resolved, nil + } + } + } + } + + return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root or set DMS_LOCAL_PATH=/absolute/path/to/repo", wd) +} + func disableDisplayManager(dmName string) (bool, error) { state, err := getSystemdServiceState(dmName) if err != nil { @@ -497,12 +724,20 @@ func enableGreeter() error { configAlreadyCorrect := isGreeterEnabled() configuredCompositor := detectConfiguredCompositor() + logFunc := func(msg string) { + fmt.Println(msg) + } + if configAlreadyCorrect { fmt.Println("✓ Greeter is already configured with dms-greeter") if configuredCompositor != "" { fmt.Printf("✓ Configured compositor: %s\n", configuredCompositor) } + 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) + } + if err := ensureGraphicalTarget(); err != nil { return err } @@ -556,13 +791,14 @@ func enableGreeter() error { } greeterPathForConfig = dmsPath } - logFunc := func(msg string) { - fmt.Println(msg) - } if err := greeter.ConfigureGreetd(greeterPathForConfig, selectedCompositor, logFunc, ""); err != nil { return fmt.Errorf("failed to configure greetd: %w", 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) + } + if err := ensureGraphicalTarget(); err != nil { return err } @@ -653,6 +889,95 @@ func readDefaultSessionCommand(configPath string) string { return "" } +func extractGreeterCacheDirFromCommand(command string) string { + if command == "" { + return greeter.GreeterCacheDir + } + tokens := strings.Fields(command) + for i := 0; i < len(tokens); i++ { + token := strings.Trim(tokens[i], "\"") + if token == "--cache-dir" && i+1 < len(tokens) { + return strings.Trim(tokens[i+1], "\"") + } + if strings.HasPrefix(token, "--cache-dir=") { + value := strings.TrimPrefix(token, "--cache-dir=") + value = strings.Trim(value, "\"") + if value != "" { + return value + } + } + } + return greeter.GreeterCacheDir +} + +func extractGreeterWrapperFromCommand(command string) string { + if command == "" { + return "" + } + tokens := strings.Fields(command) + if len(tokens) == 0 { + return "" + } + return strings.Trim(tokens[0], "\"") +} + +func extractGreeterPathOverrideFromCommand(command string) string { + if command == "" { + return "" + } + tokens := strings.Fields(command) + for i := 0; i < len(tokens); i++ { + token := strings.Trim(tokens[i], "\"") + if (token == "-p" || token == "--path") && i+1 < len(tokens) { + return strings.Trim(tokens[i+1], "\"") + } + if strings.HasPrefix(token, "--path=") { + value := strings.TrimPrefix(token, "--path=") + value = strings.Trim(value, "\"") + if value != "" { + return value + } + } + } + return "" +} + +func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) { + if pamText == "" { + return false, false, false, false + } + + lines := strings.Split(pamText, "\n") + inManaged := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + switch trimmed { + case greeter.GreeterPamManagedBlockStart: + managed = true + inManaged = true + continue + case greeter.GreeterPamManagedBlockEnd: + inManaged = false + continue + } + + if strings.HasPrefix(trimmed, "# DMS greeter fingerprint") || strings.HasPrefix(trimmed, "# DMS greeter U2F") { + legacy = true + } + if !inManaged { + continue + } + if strings.Contains(trimmed, "pam_fprintd") { + fingerprint = true + } + if strings.Contains(trimmed, "pam_u2f") { + u2f = true + } + } + + return managed, fingerprint, u2f, legacy +} + func packageInstallHint() string { osInfo, err := distros.GetOSInfo() if err != nil { @@ -731,11 +1056,19 @@ func checkGreeterStatus() error { } configPath := "/etc/greetd/config.toml" + configuredCommand := "" + allGood := true fmt.Println("Greeter Configuration:") if _, err := os.ReadFile(configPath); err == nil { - command := readDefaultSessionCommand(configPath) - if command != "" && strings.Contains(command, "dms-greeter") { + configuredCommand = readDefaultSessionCommand(configPath) + if configuredCommand != "" && strings.Contains(configuredCommand, "dms-greeter") { fmt.Println(" ✓ Greeter is enabled") + if wrapper := extractGreeterWrapperFromCommand(configuredCommand); wrapper != "" { + fmt.Printf(" Wrapper: %s\n", wrapper) + } + if pathOverride := extractGreeterPathOverrideFromCommand(configuredCommand); pathOverride != "" { + fmt.Printf(" DMS path override: %s\n", pathOverride) + } compositor := detectConfiguredCompositor() switch compositor { @@ -751,10 +1084,12 @@ func checkGreeterStatus() error { } else { fmt.Println(" ✗ Greeter is NOT enabled") fmt.Println(" Run 'dms greeter enable' to enable it") + allGood = false } } else { fmt.Println(" ✗ Greeter config not found") fmt.Printf(" %s\n", packageInstallHint()) + allGood = false } fmt.Println("\nGroup Membership:") @@ -773,8 +1108,12 @@ func checkGreeterStatus() error { fmt.Println(" Run 'dms greeter sync' to set up group membership and permissions") } - cacheDir := "/var/cache/dms-greeter" + cacheDir := extractGreeterCacheDirFromCommand(configuredCommand) fmt.Println("\nGreeter Cache Directory:") + fmt.Printf(" Effective cache dir: %s\n", cacheDir) + if cacheDir != greeter.GreeterCacheDir { + fmt.Printf(" ⚠ Non-default cache dir detected (default: %s)\n", greeter.GreeterCacheDir) + } if stat, err := os.Stat(cacheDir); err == nil && stat.IsDir() { fmt.Printf(" ✓ %s exists\n", cacheDir) } else { @@ -806,7 +1145,6 @@ func checkGreeterStatus() error { }, } - allGood := true for _, link := range symlinks { targetInfo, err := os.Lstat(link.target) if err != nil { @@ -845,11 +1183,80 @@ func checkGreeterStatus() error { fmt.Printf(" ✓ %s: synced correctly\n", link.desc) } + fmt.Println("\nGreeter Wallpaper Override:") + overridePath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg") + if stat, err := os.Stat(overridePath); err == nil && !stat.IsDir() { + fmt.Printf(" ✓ Override file present: %s\n", overridePath) + } else if os.IsNotExist(err) { + fmt.Println(" ℹ Override file not present (desktop/session wallpaper fallback in effect)") + } else if err != nil { + fmt.Printf(" ✗ Could not inspect override file: %v\n", err) + allGood = false + } else { + fmt.Printf(" ✗ Override path is not a regular file: %s\n", overridePath) + allGood = false + } + + fmt.Println("\nGreeter PAM Authentication (DMS-managed block):") + if greeter.IsNixOS() { + fmt.Println(" ℹ NixOS detected: PAM is managed by NixOS modules.") + fmt.Println(" Configure fingerprint/U2F via your greetd NixOS module (security.pam.services.greetd).") + fmt.Println() + if allGood && inGreeterGroup { + fmt.Println("✓ All checks passed! Greeter is properly configured.") + } else if !allGood { + fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to repair configuration.") + } else if !inGreeterGroup { + fmt.Printf("⚠ User is not in %s group. Run 'dms greeter sync' after adding group membership.\n", greeterGroup) + } + return nil + } + greetdPamPath := "/etc/pam.d/greetd" + pamData, err := os.ReadFile(greetdPamPath) + if err != nil { + fmt.Printf(" ✗ Failed to read %s: %v\n", greetdPamPath, err) + allGood = false + } else { + managed, managedFprint, managedU2f, legacyManaged := parseManagedGreeterPamAuth(string(pamData)) + if managed { + fmt.Println(" ✓ Managed auth block present") + if managedFprint { + fmt.Println(" - fingerprint: enabled") + } else { + fmt.Println(" - fingerprint: disabled") + } + if managedU2f { + fmt.Println(" - security key (U2F): enabled") + } else { + fmt.Println(" - security key (U2F): disabled") + } + } else { + fmt.Println(" ℹ No managed auth block present (fingerprint/U2F disabled for greeter)") + } + 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") + 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 != "" { + 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() if allGood && inGreeterGroup { fmt.Println("✓ All checks passed! Greeter is properly configured.") } else if !allGood { - fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to fix symlinks.") + fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to repair configuration.") + } else if !inGreeterGroup { + fmt.Printf("⚠ User is not in %s group. Run 'dms greeter sync' after adding group membership.\n", greeterGroup) } return nil diff --git a/core/internal/greeter/installer.go b/core/internal/greeter/installer.go index 9c6ace47..8a269087 100644 --- a/core/internal/greeter/installer.go +++ b/core/internal/greeter/installer.go @@ -3,6 +3,7 @@ package greeter import ( "bufio" "context" + "encoding/json" "fmt" "os" "os/exec" @@ -17,10 +18,29 @@ import ( "github.com/sblinch/kdl-go/document" ) +const ( + GreeterCacheDir = "/var/cache/dms-greeter" + + GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)" + GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH" + + legacyGreeterPamFprintComment = "# DMS greeter fingerprint" + legacyGreeterPamU2FComment = "# DMS greeter U2F" +) + +var includedPamAuthFiles = []string{"system-auth", "common-auth", "password-auth"} + 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 must not 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 { @@ -201,17 +221,29 @@ func DetectGreeterUser() string { } func resolveGreeterWrapperPath() string { - if path, err := exec.LookPath("dms-greeter"); err == nil { - return path + if override := strings.TrimSpace(os.Getenv("DMS_GREETER_WRAPPER_CMD")); override != "" { + return override } - for _, candidate := range []string{"/usr/local/bin/dms-greeter", "/usr/bin/dms-greeter"} { - if _, err := os.Stat(candidate); err == nil { + 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 } } - return "dms-greeter" + 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 { @@ -514,7 +546,21 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass } } - cacheDir := "/var/cache/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). +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) } @@ -526,11 +572,10 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass return fmt.Errorf("failed to set cache directory owner: %w", err) } - if err := runSudoCmd(sudoPassword, "chmod", "755", cacheDir); err != nil { + if err := runSudoCmd(sudoPassword, "chmod", "750", cacheDir); err != nil { return fmt.Errorf("failed to set cache directory permissions: %w", err) } - logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: %s, permissions: 755)", cacheDir, owner)) - + logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: %s, mode: 750)", cacheDir, owner)) return nil } @@ -730,13 +775,13 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error { return nil } -func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { +func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string, forceAuth bool) error { homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get user home directory: %w", err) } - cacheDir := "/var/cache/dms-greeter" + cacheDir := GreeterCacheDir symlinks := []struct { source string @@ -764,28 +809,33 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo sourceDir := filepath.Dir(link.source) if _, err := os.Stat(sourceDir); os.IsNotExist(err) { if err := os.MkdirAll(sourceDir, 0o755); err != nil { - logFunc(fmt.Sprintf("⚠ Warning: Could not create directory %s: %v", sourceDir, err)) - continue + 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 { - logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", link.source, err)) - continue + 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 { - logFunc(fmt.Sprintf("⚠ Warning: Failed to create symlink for %s: %v", link.desc, err)) - continue + 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)) } + if err := syncGreeterWallpaperOverride(homeDir, cacheDir, logFunc, sudoPassword); err != nil { + return fmt.Errorf("greeter wallpaper override sync failed: %w", err) + } + + if err := syncGreeterPamConfig(homeDir, logFunc, sudoPassword, forceAuth); err != nil { + return fmt.Errorf("greeter PAM config sync failed: %w", err) + } + if strings.ToLower(compositor) != "niri" { return nil } @@ -797,6 +847,293 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo return nil } +func syncGreeterWallpaperOverride(homeDir, cacheDir string, logFunc func(string), sudoPassword string) error { + settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read settings at %s: %w", settingsPath, err) + } + var settings struct { + GreeterWallpaperPath string `json:"greeterWallpaperPath"` + } + if err := json.Unmarshal(data, &settings); err != nil { + return fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err) + } + destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg") + if settings.GreeterWallpaperPath == "" { + 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 := settings.GreeterWallpaperPath + if !filepath.IsAbs(src) { + src = filepath.Join(homeDir, src) + } + 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() + if err := runSudoCmd(sudoPassword, "chown", "greeter:"+greeterGroup, destPath); err != 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 +} + +func pamModuleExists(module string) bool { + for _, libDir := range []string{ + "/usr/lib64/security", + "/usr/lib/security", + "/lib/x86_64-linux-gnu/security", + "/usr/lib/x86_64-linux-gnu/security", + "/usr/lib/aarch64-linux-gnu/security", + } { + if _, err := os.Stat(filepath.Join(libDir, module)); err == nil { + return true + } + } + return false +} + +func stripManagedGreeterPamBlock(content string) (string, bool) { + lines := strings.Split(content, "\n") + filtered := make([]string, 0, len(lines)) + inManagedBlock := false + removed := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == GreeterPamManagedBlockStart { + inManagedBlock = true + removed = true + continue + } + if trimmed == GreeterPamManagedBlockEnd { + inManagedBlock = false + removed = true + continue + } + if inManagedBlock { + removed = true + continue + } + filtered = append(filtered, line) + } + + return strings.Join(filtered, "\n"), removed +} + +func stripLegacyGreeterPamLines(content string) (string, bool) { + lines := strings.Split(content, "\n") + filtered := make([]string, 0, len(lines)) + removed := false + + for i := 0; i < len(lines); i++ { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) { + removed = true + if i+1 < len(lines) { + nextLine := strings.TrimSpace(lines[i+1]) + if strings.HasPrefix(nextLine, "auth") && + (strings.Contains(nextLine, "pam_fprintd") || strings.Contains(nextLine, "pam_u2f")) { + i++ + } + } + continue + } + filtered = append(filtered, lines[i]) + } + + return strings.Join(filtered, "\n"), removed +} + +func insertManagedGreeterPamBlock(content string, blockLines []string, greetdPamPath string) (string, error) { + lines := strings.Split(content, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "auth") { + block := strings.Join(blockLines, "\n") + prefix := strings.Join(lines[:i], "\n") + suffix := strings.Join(lines[i:], "\n") + switch { + case prefix == "": + return block + "\n" + suffix, nil + case suffix == "": + return prefix + "\n" + block, nil + default: + return prefix + "\n" + block + "\n" + suffix, nil + } + } + } + return "", fmt.Errorf("no auth directive found in %s", greetdPamPath) +} + +func PamTextIncludesFile(pamText, filename string) bool { + lines := strings.Split(pamText, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, filename) && + (strings.Contains(trimmed, "include") || strings.Contains(trimmed, "substack") || strings.HasPrefix(trimmed, "@include")) { + return true + } + } + return false +} + +func PamFileHasModule(pamFilePath, module string) bool { + data, err := os.ReadFile(pamFilePath) + if err != nil { + return false + } + lines := strings.Split(string(data), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, module) { + return true + } + } + return false +} + +func DetectIncludedPamModule(pamText, module string) string { + for _, includedFile := range includedPamAuthFiles { + if PamTextIncludesFile(pamText, includedFile) && PamFileHasModule("/etc/pam.d/"+includedFile, module) { + return includedFile + } + } + return "" +} + +func syncGreeterPamConfig(homeDir string, logFunc func(string), sudoPassword string, forceAuth bool) error { + var wantFprint, wantU2f bool + 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) + 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) + } + fprintModule := pamModuleExists("pam_fprintd.so") + u2fModule := pamModuleExists("pam_u2f.so") + wantFprint = settings.GreeterEnableFprint && fprintModule + wantU2f = settings.GreeterEnableU2f && u2fModule + if settings.GreeterEnableFprint && !fprintModule { + logFunc("⚠ Warning: greeter fingerprint toggle is enabled, but pam_fprintd.so was not found.") + } + if settings.GreeterEnableU2f && !u2fModule { + logFunc("⚠ Warning: greeter security key toggle is enabled, but pam_u2f.so was not found.") + } + } + + if IsNixOS() { + logFunc("ℹ NixOS detected: PAM config is managed by NixOS modules. Skipping DMS PAM block write.") + logFunc(" Configure fingerprint/U2F auth via your greetd NixOS module options (e.g. security.pam.services.greetd).") + return nil + } + + greetdPamPath := "/etc/pam.d/greetd" + pamData, err := os.ReadFile(greetdPamPath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", greetdPamPath, err) + } + originalContent := string(pamData) + content, _ := stripManagedGreeterPamBlock(originalContent) + content, _ = stripLegacyGreeterPamLines(content) + + includedFprintFile := DetectIncludedPamModule(content, "pam_fprintd.so") + if wantFprint && includedFprintFile != "" { + 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 != "" { + 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.") + } + + if wantFprint || wantU2f { + blockLines := []string{GreeterPamManagedBlockStart} + if wantFprint { + blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") + } + if wantU2f { + blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10") + } + blockLines = append(blockLines, GreeterPamManagedBlockEnd) + + content, err = insertManagedGreeterPamBlock(content, blockLines, greetdPamPath) + if err != nil { + return err + } + } + + if content == originalContent { + return nil + } + + tmpFile, err := os.CreateTemp("", "greetd-pam-*.conf") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + if _, err := tmpFile.WriteString(content); err != nil { + tmpFile.Close() + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + if err := runSudoCmd(sudoPassword, "cp", tmpPath, greetdPamPath); err != nil { + return fmt.Errorf("failed to install updated PAM config at %s: %w", greetdPamPath, err) + } + if err := runSudoCmd(sudoPassword, "chmod", "644", greetdPamPath); err != nil { + return fmt.Errorf("failed to set permissions on %s: %w", greetdPamPath, err) + } + if wantFprint || wantU2f { + logFunc("✓ Configured greetd PAM for fingerprint/U2F") + } else { + logFunc("✓ Cleared DMS-managed greeter PAM auth block") + } + return nil +} + type niriGreeterSync struct { processed map[string]bool nodes []*document.Node @@ -938,6 +1275,8 @@ func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfi } // Strip existing -C or --config and their arguments 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") @@ -954,10 +1293,6 @@ func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfi return nil } - if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil { - return fmt.Errorf("failed to backup greetd config: %w", err) - } - tmpFile, err := os.CreateTemp("", "greetd-config-*.toml") if err != nil { return fmt.Errorf("failed to create temp greetd config: %w", err) @@ -988,7 +1323,10 @@ func backupFileIfExists(sudoPassword string, path string, suffix string) error { } backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405")) - return runSudoCmd(sudoPassword, "cp", "-p", path, backupPath) + if err := runSudoCmd(sudoPassword, "cp", path, backupPath); err != nil { + return err + } + return runSudoCmd(sudoPassword, "chmod", "644", backupPath) } func (s *niriGreeterSync) processFile(filePath string) error { @@ -1134,14 +1472,12 @@ func (s *niriGreeterSync) render() 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 { - backupPath := configPath + ".backup" - if err := runSudoCmd(sudoPassword, "cp", configPath, backupPath); err != nil { - return fmt.Errorf("failed to backup config: %w", err) - } logFunc(fmt.Sprintf("✓ Backed up existing config to %s", backupPath)) - } else if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to access %s: %w", configPath, err) } greeterUser := DetectGreeterUser() @@ -1164,7 +1500,7 @@ vt = 1 // Build command based on compositor and dms path // When dmsPath is empty (packaged greeter), omit -p; wrapper finds /usr/share/quickshell/dms-greeter compositorLower := strings.ToLower(compositor) - commandValue := fmt.Sprintf("%s --command %s", wrapperCmd, compositorLower) + commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir) if dmsPath != "" { commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath) } @@ -1229,6 +1565,30 @@ func stripConfigFlag(command string) string { 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) @@ -1405,7 +1765,7 @@ func AutoSetupGreeter(compositor, sudoPassword string, logFunc func(string)) err } logFunc("Synchronizing DMS configurations...") - if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword); err != nil { + if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword, false); err != nil { logFunc(fmt.Sprintf("⚠ Warning: config sync error: %v", err)) } diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index b9d6f1ba..c9dc0e70 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -1204,7 +1204,7 @@ Singleton { id: greeterSessionFile path: { - const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/etc/greetd/.dms"; + const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"; return greetCfgDir + "/session.json"; } preload: isGreeterMode diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 8e59c17a..c5b2a731 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -296,6 +296,15 @@ Singleton { property string lockDateFormat: "" property bool greeterRememberLastSession: true property bool greeterRememberLastUser: true + property bool greeterEnableFprint: false + property bool greeterEnableU2f: false + property string greeterWallpaperPath: "" + property bool greeterUse24HourClock: true + property bool greeterShowSeconds: false + property bool greeterPadHours12Hour: false + property string greeterLockDateFormat: "" + property string greeterFontFamily: "" + property string greeterWallpaperFillMode: "" property int mediaSize: 1 property string appLauncherViewMode: "list" diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index 63f88e2b..eb8f4dbd 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -858,7 +858,7 @@ Singleton { property string fontFamily: { if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") { - return GreetdSettings.fontFamily; + return GreetdSettings.getEffectiveFontFamily(); } return typeof SettingsData !== "undefined" ? SettingsData.fontFamily : "Inter Variable"; } @@ -1763,7 +1763,7 @@ Singleton { FileView { id: dynamicColorsFileView path: { - const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/etc/greetd/.dms"; + const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"; const colorsPath = SessionData.isGreeterMode ? greetCfgDir + "/colors.json" : stateDir + "/dms-colors.json"; return colorsPath; } diff --git a/quickshell/Common/settings/Processes.qml b/quickshell/Common/settings/Processes.qml index d14cf6fa..50cc2e38 100644 --- a/quickshell/Common/settings/Processes.qml +++ b/quickshell/Common/settings/Processes.qml @@ -48,7 +48,7 @@ Singleton { } property var fprintdDetectionProcess: Process { - command: ["sh", "-c", "command -v fprintd-list >/dev/null 2>&1"] + command: ["sh", "-c", "command -v fprintd-list >/dev/null 2>&1 && fprintd-list \"${USER:-$(id -un)}\" >/dev/null 2>&1"] running: false onExited: function (exitCode) { if (!settingsRoot) diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 0cb64ee5..0e29dbe2 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -156,6 +156,15 @@ var SPEC = { lockDateFormat: { def: "" }, greeterRememberLastSession: { def: true }, greeterRememberLastUser: { def: true }, + greeterEnableFprint: { def: false }, + greeterEnableU2f: { def: false }, + greeterWallpaperPath: { def: "" }, + greeterUse24HourClock: { def: true }, + greeterShowSeconds: { def: false }, + greeterPadHours12Hour: { def: false }, + greeterLockDateFormat: { def: "" }, + greeterFontFamily: { def: "" }, + greeterWallpaperFillMode: { def: "" }, mediaSize: { def: 1 }, appLauncherViewMode: { def: "list" }, diff --git a/quickshell/Modals/PolkitAuthModal.qml b/quickshell/Modals/PolkitAuthModal.qml index d714cdeb..21ddf8fd 100644 --- a/quickshell/Modals/PolkitAuthModal.qml +++ b/quickshell/Modals/PolkitAuthModal.qml @@ -1,5 +1,6 @@ import QtQuick import Quickshell +import Quickshell.Io import qs.Common import qs.Services import qs.Widgets @@ -11,8 +12,45 @@ FloatingWindow { property string passwordInput: "" property var currentFlow: PolkitService.agent?.flow property bool isLoading: false + property bool awaitingFprintForPassword: false readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2 + property string polkitEtcPamText: "" + property string polkitLibPamText: "" + property string systemAuthPamText: "" + property string commonAuthPamText: "" + property string passwordAuthPamText: "" + readonly property bool polkitPamHasFprint: { + const polkitText = polkitEtcPamText !== "" ? polkitEtcPamText : polkitLibPamText; + if (!polkitText) + return false; + return pamModuleEnabled(polkitText, "pam_fprintd") || (polkitText.includes("system-auth") && pamModuleEnabled(systemAuthPamText, "pam_fprintd")) || (polkitText.includes("common-auth") && pamModuleEnabled(commonAuthPamText, "pam_fprintd")) || (polkitText.includes("password-auth") && pamModuleEnabled(passwordAuthPamText, "pam_fprintd")); + } + + function stripPamComment(line) { + if (!line) + return ""; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) + return ""; + const hashIdx = trimmed.indexOf("#"); + if (hashIdx >= 0) + return trimmed.substring(0, hashIdx).trim(); + return trimmed; + } + + function pamModuleEnabled(pamText, moduleName) { + if (!pamText || !moduleName) + return false; + const lines = pamText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = stripPamComment(lines[i]); + if (line && line.includes(moduleName)) + return true; + } + return false; + } + function focusPasswordField() { passwordField.forceActiveFocus(); } @@ -20,6 +58,7 @@ FloatingWindow { function show() { passwordInput = ""; isLoading = false; + awaitingFprintForPassword = false; visible = true; Qt.callLater(focusPasswordField); } @@ -28,17 +67,27 @@ FloatingWindow { visible = false; } + function _commitSubmit() { + isLoading = true; + awaitingFprintForPassword = false; + currentFlow.submit(passwordInput); + passwordInput = ""; + } + function submitAuth() { if (!currentFlow || isLoading) return; - isLoading = true; - currentFlow.submit(passwordInput); - passwordInput = ""; + if (!currentFlow.isResponseRequired) { + awaitingFprintForPassword = true; + return; + } + _commitSubmit(); } function cancelAuth() { if (isLoading) return; + awaitingFprintForPassword = false; if (currentFlow) { currentFlow.cancelAuthenticationRequest(); return; @@ -60,6 +109,7 @@ FloatingWindow { } passwordInput = ""; isLoading = false; + awaitingFprintForPassword = false; } Connections { @@ -83,6 +133,11 @@ FloatingWindow { function onIsResponseRequiredChanged() { if (!currentFlow.isResponseRequired) return; + if (awaitingFprintForPassword && passwordInput !== "") { + _commitSubmit(); + return; + } + awaitingFprintForPassword = false; isLoading = false; passwordInput = ""; passwordField.forceActiveFocus(); @@ -101,6 +156,41 @@ FloatingWindow { } } + FileView { + path: "/etc/pam.d/polkit-1" + printErrors: false + onLoaded: root.polkitEtcPamText = text() + onLoadFailed: root.polkitEtcPamText = "" + } + + FileView { + path: "/usr/lib/pam.d/polkit-1" + printErrors: false + onLoaded: root.polkitLibPamText = text() + onLoadFailed: root.polkitLibPamText = "" + } + + FileView { + path: "/etc/pam.d/system-auth" + printErrors: false + onLoaded: root.systemAuthPamText = text() + onLoadFailed: root.systemAuthPamText = "" + } + + FileView { + path: "/etc/pam.d/common-auth" + printErrors: false + onLoaded: root.commonAuthPamText = text() + onLoadFailed: root.commonAuthPamText = "" + } + + FileView { + path: "/etc/pam.d/password-auth" + printErrors: false + onLoaded: root.passwordAuthPamText = text() + onLoadFailed: root.passwordAuthPamText = "" + } + FocusScope { id: contentFocusScope @@ -205,36 +295,30 @@ FloatingWindow { visible: text !== "" } - Rectangle { + DankTextField { + id: passwordField + width: parent.width height: inputFieldHeight - radius: Theme.cornerRadius - color: Theme.surfaceHover - border.color: passwordField.activeFocus ? Theme.primary : Theme.outlineStrong - border.width: passwordField.activeFocus ? 2 : 1 + backgroundColor: Theme.surfaceHover + normalBorderColor: Theme.outlineStrong + focusedBorderColor: Theme.primary + borderWidth: 1 + focusedBorderWidth: 2 + leftIconName: polkitPamHasFprint ? "fingerprint" : "" + leftIconSize: 20 + leftIconColor: Theme.primary + leftIconFocusedColor: Theme.primary opacity: isLoading ? 0.5 : 1 - - MouseArea { - anchors.fill: parent - enabled: !isLoading - onClicked: passwordField.forceActiveFocus() - } - - DankTextField { - id: passwordField - - anchors.fill: parent - font.pixelSize: Theme.fontSizeMedium - textColor: Theme.surfaceText - text: passwordInput - showPasswordToggle: !(currentFlow?.responseVisible ?? false) - echoMode: (currentFlow?.responseVisible ?? false) || passwordVisible ? TextInput.Normal : TextInput.Password - placeholderText: "" - backgroundColor: "transparent" - enabled: !isLoading - onTextEdited: passwordInput = text - onAccepted: submitAuth() - } + font.pixelSize: Theme.fontSizeMedium + textColor: Theme.surfaceText + text: passwordInput + showPasswordToggle: !(currentFlow?.responseVisible ?? false) + echoMode: (currentFlow?.responseVisible ?? false) || passwordVisible ? TextInput.Normal : TextInput.Password + placeholderText: "" + enabled: !isLoading + onTextEdited: passwordInput = text + onAccepted: submitAuth() } StyledText { diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 107e0534..ddfc1293 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -241,6 +241,21 @@ FocusScope { } } + Loader { + id: greeterLoader + anchors.fill: parent + active: root.currentIndex === 31 + visible: active + focus: active + + sourceComponent: GreeterTab {} + + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } + Loader { id: pluginsLoader anchors.fill: parent diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 1b93b531..52854091 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -281,6 +281,12 @@ Rectangle { "icon": "lock", "tabIndex": 11 }, + { + "id": "greeter", + "text": I18n.tr("Greeter"), + "icon": "login", + "tabIndex": 31 + }, { "id": "power_sleep", "text": I18n.tr("Power & Sleep"), diff --git a/quickshell/Modules/Greetd/GreetdMemory.qml b/quickshell/Modules/Greetd/GreetdMemory.qml index dea72a64..6c8f9cb5 100644 --- a/quickshell/Modules/Greetd/GreetdMemory.qml +++ b/quickshell/Modules/Greetd/GreetdMemory.qml @@ -9,7 +9,7 @@ import "GreetdEnv.js" as GreetdEnv Singleton { id: root - readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/etc/greetd/.dms" + 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 bool rememberLastSession: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_SESSION", "DMS_SAVE_SESSION"], true) diff --git a/quickshell/Modules/Greetd/GreetdSettings.qml b/quickshell/Modules/Greetd/GreetdSettings.qml index 98b9a921..8de70a82 100644 --- a/quickshell/Modules/Greetd/GreetdSettings.qml +++ b/quickshell/Modules/Greetd/GreetdSettings.qml @@ -11,10 +11,16 @@ Singleton { id: root readonly property string configPath: { - const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/etc/greetd/.dms"; + const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"; return greetCfgDir + "/settings.json"; } + readonly property string _greeterCacheDir: { + const i = root.configPath.lastIndexOf("/"); + return i >= 0 ? root.configPath.substring(0, i) : ""; + } + readonly property string greeterWallpaperOverridePath: root._greeterCacheDir ? (root._greeterCacheDir + "/greeter_wallpaper_override.jpg") : "" + property string currentThemeName: "purple" property bool settingsLoaded: false property string customThemeFile: "" @@ -22,6 +28,12 @@ Singleton { property bool use24HourClock: true property bool showSeconds: false property bool padHours12Hour: false + property bool greeterUse24HourClock: true + property bool greeterShowSeconds: false + property bool greeterPadHours12Hour: false + property string greeterLockDateFormat: "" + property string greeterFontFamily: "" + property string greeterWallpaperFillMode: "" property bool useFahrenheit: false property bool nightModeEnabled: false property string weatherLocation: "New York, NY" @@ -44,6 +56,9 @@ Singleton { property bool lockScreenShowProfileImage: true property bool rememberLastSession: true property bool rememberLastUser: true + property bool greeterEnableFprint: false + property bool greeterEnableU2f: false + property string greeterWallpaperPath: "" property bool powerActionConfirm: true property real powerActionHoldDuration: 0.5 property var powerMenuActions: ["reboot", "logout", "poweroff", "lock", "suspend", "restart"] @@ -69,6 +84,12 @@ Singleton { use24HourClock = settings.use24HourClock !== undefined ? settings.use24HourClock : true; showSeconds = settings.showSeconds !== undefined ? settings.showSeconds : false; padHours12Hour = settings.padHours12Hour !== undefined ? settings.padHours12Hour : false; + greeterUse24HourClock = settings.greeterUse24HourClock !== undefined ? settings.greeterUse24HourClock : use24HourClock; + greeterShowSeconds = settings.greeterShowSeconds !== undefined ? settings.greeterShowSeconds : showSeconds; + greeterPadHours12Hour = settings.greeterPadHours12Hour !== undefined ? settings.greeterPadHours12Hour : padHours12Hour; + greeterLockDateFormat = settings.greeterLockDateFormat !== undefined ? settings.greeterLockDateFormat : ""; + greeterFontFamily = settings.greeterFontFamily !== undefined ? settings.greeterFontFamily : ""; + greeterWallpaperFillMode = settings.greeterWallpaperFillMode !== undefined ? settings.greeterWallpaperFillMode : ""; useFahrenheit = settings.useFahrenheit !== undefined ? settings.useFahrenheit : false; nightModeEnabled = settings.nightModeEnabled !== undefined ? settings.nightModeEnabled : false; weatherLocation = settings.weatherLocation !== undefined ? settings.weatherLocation : "New York, NY"; @@ -92,17 +113,16 @@ Singleton { if (envRememberLastSession !== undefined) { rememberLastSession = envRememberLastSession; } else { - rememberLastSession = settings.greeterRememberLastSession !== undefined - ? settings.greeterRememberLastSession - : settings.rememberLastSession !== undefined ? settings.rememberLastSession : true; + rememberLastSession = settings.greeterRememberLastSession !== undefined ? settings.greeterRememberLastSession : settings.rememberLastSession !== undefined ? settings.rememberLastSession : true; } if (envRememberLastUser !== undefined) { rememberLastUser = envRememberLastUser; } else { - rememberLastUser = settings.greeterRememberLastUser !== undefined - ? settings.greeterRememberLastUser - : settings.rememberLastUser !== undefined ? settings.rememberLastUser : true; + rememberLastUser = settings.greeterRememberLastUser !== undefined ? settings.greeterRememberLastUser : settings.rememberLastUser !== undefined ? settings.rememberLastUser : true; } + greeterEnableFprint = settings.greeterEnableFprint !== undefined ? settings.greeterEnableFprint : false; + greeterEnableU2f = settings.greeterEnableU2f !== undefined ? settings.greeterEnableU2f : false; + greeterWallpaperPath = settings.greeterWallpaperPath !== undefined ? settings.greeterWallpaperPath : ""; powerActionConfirm = settings.powerActionConfirm !== undefined ? settings.powerActionConfirm : true; powerActionHoldDuration = settings.powerActionHoldDuration !== undefined ? settings.powerActionHoldDuration : 0.5; powerMenuActions = settings.powerMenuActions !== undefined ? settings.powerMenuActions : ["reboot", "logout", "poweroff", "lock", "suspend", "restart"]; @@ -126,15 +146,27 @@ Singleton { } function getEffectiveTimeFormat() { - if (use24HourClock) - return showSeconds ? "hh:mm:ss" : "hh:mm"; - if (padHours12Hour) - return showSeconds ? "hh:mm:ss AP" : "hh:mm AP"; - return showSeconds ? "h:mm:ss AP" : "h:mm AP"; + const use24 = greeterUse24HourClock; + const secs = greeterShowSeconds; + const pad = greeterPadHours12Hour; + if (use24) + return secs ? "hh:mm:ss" : "hh:mm"; + if (pad) + return secs ? "hh:mm:ss AP" : "hh:mm AP"; + return secs ? "h:mm:ss AP" : "h:mm AP"; } function getEffectiveLockDateFormat() { - return lockDateFormat && lockDateFormat.length > 0 ? lockDateFormat : Locale.LongFormat; + const fmt = (greeterLockDateFormat !== undefined && greeterLockDateFormat !== "") ? greeterLockDateFormat : lockDateFormat; + return fmt && fmt.length > 0 ? fmt : Locale.LongFormat; + } + + function getEffectiveWallpaperFillMode() { + return (greeterWallpaperFillMode && greeterWallpaperFillMode !== "") ? greeterWallpaperFillMode : wallpaperFillMode; + } + + function getEffectiveFontFamily() { + return (greeterFontFamily && greeterFontFamily !== "") ? greeterFontFamily : fontFamily; } function getFilteredScreens(componentId) { diff --git a/quickshell/Modules/Greetd/GreeterContent.qml b/quickshell/Modules/Greetd/GreeterContent.qml index 7fb99373..f9c8097d 100644 --- a/quickshell/Modules/Greetd/GreeterContent.qml +++ b/quickshell/Modules/Greetd/GreeterContent.qml @@ -32,6 +32,9 @@ Item { property bool weatherInitialized: false property bool awaitingExternalAuth: false + property bool pendingPasswordResponse: false + property bool passwordSubmitRequested: false + property bool cancelingExternalAuthForPassword: false property int defaultAuthTimeoutMs: 12000 property int externalAuthTimeoutMs: 45000 property int passwordFailureCount: 0 @@ -42,6 +45,11 @@ Item { property string commonAuthPamText: "" property string passwordAuthPamText: "" property string faillockConfigText: "" + property bool greeterWallpaperOverrideExists: false + 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 function initWeatherService() { if (weatherInitialized) @@ -67,6 +75,20 @@ Item { return trimmed; } + function pamModuleEnabled(pamText, moduleName) { + if (!pamText || !moduleName) + return false; + const lines = pamText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = stripPamComment(lines[i]); + if (!line) + continue; + if (line.includes(moduleName)) + return true; + } + return false; + } + function usesPamLockoutPolicy(pamText) { if (!pamText) return false; @@ -221,6 +243,7 @@ Item { onLoaded: { root.greetdPamText = text(); root.refreshPasswordAttemptPolicyHint(); + root.maybeAutoStartExternalAuth(); } onLoadFailed: { root.greetdPamText = ""; @@ -304,6 +327,7 @@ Item { GreeterState.usernameInput = lastUser; GreeterState.showPasswordInput = true; PortalService.getGreeterUserProfileImage(lastUser); + maybeAutoStartExternalAuth(); } } @@ -314,26 +338,92 @@ Item { if (GreeterState.username !== user) { passwordFailureCount = 0; clearAuthFeedback(); + externalAuthAutoStartedForUser = ""; } GreeterState.username = user; GreeterState.showPasswordInput = true; PortalService.getGreeterUserProfileImage(user); GreeterState.passwordBuffer = ""; + pendingPasswordResponse = false; + passwordSubmitRequested = false; + cancelingExternalAuthForPassword = false; + maybeAutoStartExternalAuth(); + } + + function submitBufferedPassword() { + if (!GreeterState.passwordBuffer || GreeterState.passwordBuffer.length === 0) + return false; + pendingPasswordResponse = false; + passwordSubmitRequested = false; + cancelingExternalAuthForPassword = false; + awaitingExternalAuth = false; + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.restart(); + Greetd.respond(GreeterState.passwordBuffer); + GreeterState.passwordBuffer = ""; + inputField.text = ""; + return true; + } + + function requestPasswordSessionTransition() { + if (cancelingExternalAuthForPassword) + return; + cancelingExternalAuthForPassword = true; + awaitingExternalAuth = false; + pendingPasswordResponse = false; + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.stop(); + Greetd.cancelSession(); } function startAuthSession() { if (!GreeterState.showPasswordInput || !GreeterState.username) return; - if (GreeterState.unlocking || Greetd.state !== GreetdState.Inactive) + if (GreeterState.unlocking) return; - if (!GreeterState.passwordBuffer || GreeterState.passwordBuffer.length === 0) + const hasPasswordBuffer = GreeterState.passwordBuffer && GreeterState.passwordBuffer.length > 0; + if (Greetd.state !== GreetdState.Inactive) { + if (pendingPasswordResponse && hasPasswordBuffer) + submitBufferedPassword(); + else if (awaitingExternalAuth && hasPasswordBuffer) { + passwordSubmitRequested = true; + } else if (hasPasswordBuffer) + passwordSubmitRequested = true; return; - awaitingExternalAuth = false; - authTimeout.interval = defaultAuthTimeoutMs; + } + if (cancelingExternalAuthForPassword) { + if (hasPasswordBuffer) + passwordSubmitRequested = true; + return; + } + if (!hasPasswordBuffer && !root.greeterExternalAuthAvailable) + return; + pendingPasswordResponse = false; + passwordSubmitRequested = hasPasswordBuffer; + awaitingExternalAuth = !hasPasswordBuffer && root.greeterExternalAuthAvailable; + authTimeout.interval = awaitingExternalAuth ? externalAuthTimeoutMs : defaultAuthTimeoutMs; authTimeout.restart(); Greetd.createSession(GreeterState.username); } + function maybeAutoStartExternalAuth() { + if (!GreeterState.showPasswordInput || !GreeterState.username) + return; + if (!root.greeterExternalAuthAvailable) + return; + if (GreeterState.unlocking || Greetd.state !== GreetdState.Inactive) + return; + if (passwordSubmitRequested || cancelingExternalAuthForPassword) + return; + if (GreeterState.passwordBuffer && GreeterState.passwordBuffer.length > 0) + return; + if (externalAuthAutoStartedForUser === GreeterState.username) + return; + + externalAuthAutoStartedForUser = GreeterState.username; + startAuthSession(); + } + function isExternalAuthPrompt(message, responseRequired) { // Non-response PAM messages commonly represent waiting states (fprint/U2F/token touch). return !responseRequired; @@ -410,10 +500,39 @@ Item { } } + FileView { + id: greeterWallpaperOverrideFile + path: GreetdSettings.greeterWallpaperOverridePath + printErrors: false + watchChanges: true + onLoaded: root.greeterWallpaperOverrideExists = true + onLoadFailed: root.greeterWallpaperOverrideExists = false + } + + Connections { + target: GreetdSettings + function onGreeterWallpaperOverridePathChanged() { + if (!GreetdSettings.greeterWallpaperOverridePath) { + root.greeterWallpaperOverrideExists = false; + return; + } + greeterWallpaperOverrideFile.reload(); + } + function onGreeterWallpaperPathChanged() { + if (!GreetdSettings.greeterWallpaperPath) { + root.greeterWallpaperOverrideExists = false; + return; + } + greeterWallpaperOverrideFile.reload(); + } + } + DankBackdrop { anchors.fill: parent screenName: root.screenName visible: { + if (GreetdSettings.greeterWallpaperPath !== "" && root.greeterWallpaperOverrideExists) + return false; var _ = SessionData.perMonitorWallpaper; var __ = SessionData.monitorWallpapers; var currentWallpaper = SessionData.getMonitorWallpaper(screenName); @@ -426,12 +545,14 @@ Item { anchors.fill: parent source: { + if (GreetdSettings.greeterWallpaperPath !== "" && root.greeterWallpaperOverrideExists) + return encodeFileUrl(GreetdSettings.greeterWallpaperOverridePath); var _ = SessionData.perMonitorWallpaper; var __ = SessionData.monitorWallpapers; var currentWallpaper = SessionData.getMonitorWallpaper(screenName); return (currentWallpaper && !currentWallpaper.startsWith("#")) ? encodeFileUrl(currentWallpaper) : ""; } - fillMode: Theme.getFillMode(GreetdSettings.wallpaperFillMode) + fillMode: Theme.getFillMode(GreetdSettings.getEffectiveWallpaperFillMode()) smooth: true asynchronous: false cache: true @@ -594,10 +715,7 @@ Item { anchors.top: clockContainer.bottom anchors.topMargin: 4 text: { - if (GreetdSettings.lockDateFormat && GreetdSettings.lockDateFormat.length > 0) { - return systemClock.date.toLocaleDateString(Qt.locale(), GreetdSettings.lockDateFormat); - } - return systemClock.date.toLocaleDateString(Qt.locale(), Locale.LongFormat); + return systemClock.date.toLocaleDateString(Qt.locale(), GreetdSettings.getEffectiveLockDateFormat()); } font.pixelSize: Theme.fontSizeXLarge color: "white" @@ -666,6 +784,9 @@ Item { if (GreeterState.showPasswordInput && revealButton.visible) { margin += revealButton.width; } + if (externalAuthButton.visible) { + margin += externalAuthButton.width; + } if (virtualKeyboardButton.visible) { margin += virtualKeyboardButton.width; } @@ -682,6 +803,8 @@ Item { return; if (GreeterState.showPasswordInput) { GreeterState.passwordBuffer = text; + if (!text || text.length === 0) + root.passwordSubmitRequested = false; } else { GreeterState.usernameInput = text; } @@ -723,14 +846,14 @@ Item { anchors.left: lockIcon.right anchors.leftMargin: Theme.spacingM - anchors.right: (GreeterState.showPasswordInput && revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right))) + anchors.right: (GreeterState.showPasswordInput && revealButton.visible ? revealButton.left : (externalAuthButton.visible ? externalAuthButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right)))) anchors.rightMargin: 2 anchors.verticalCenter: parent.verticalCenter text: { if (GreeterState.unlocking) { return "Logging in..."; } - if (Greetd.state !== GreetdState.Inactive) { + if (Greetd.state !== GreetdState.Inactive && !awaitingExternalAuth && !pendingPasswordResponse) { return "Authenticating..."; } if (GreeterState.showPasswordInput) { @@ -738,7 +861,7 @@ Item { } return "Username..."; } - color: GreeterState.unlocking ? Theme.primary : (Greetd.state !== GreetdState.Inactive ? Theme.primary : Theme.outline) + color: (GreeterState.unlocking || (Greetd.state !== GreetdState.Inactive && !awaitingExternalAuth && !pendingPasswordResponse)) ? Theme.primary : Theme.outline font.pixelSize: Theme.fontSizeMedium opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length === 0 : GreeterState.usernameInput.length === 0) ? 1 : 0 @@ -760,7 +883,7 @@ Item { StyledText { anchors.left: lockIcon.right anchors.leftMargin: Theme.spacingM - anchors.right: (GreeterState.showPasswordInput && revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right))) + anchors.right: (GreeterState.showPasswordInput && revealButton.visible ? revealButton.left : (externalAuthButton.visible ? externalAuthButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right)))) anchors.rightMargin: 2 anchors.verticalCenter: parent.verticalCenter text: { @@ -790,15 +913,27 @@ Item { DankActionButton { id: revealButton - anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right) + anchors.right: externalAuthButton.visible ? externalAuthButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right)) anchors.rightMargin: 0 anchors.verticalCenter: parent.verticalCenter iconName: parent.showPassword ? "visibility_off" : "visibility" buttonSize: 32 - visible: GreeterState.showPasswordInput && GreeterState.passwordBuffer.length > 0 && Greetd.state === GreetdState.Inactive && !GreeterState.unlocking + visible: GreeterState.showPasswordInput && GreeterState.passwordBuffer.length > 0 && (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking enabled: visible onClicked: parent.showPassword = !parent.showPassword } + DankActionButton { + id: externalAuthButton + + anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right) + anchors.rightMargin: 0 + anchors.verticalCenter: parent.verticalCenter + iconName: root.greeterPamHasFprint ? "fingerprint" : "key" + buttonSize: 32 + visible: GreeterState.showPasswordInput && root.greeterExternalAuthAvailable && GreeterState.passwordBuffer.length === 0 && (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking + enabled: visible + onClicked: root.startAuthSession() + } DankActionButton { id: virtualKeyboardButton @@ -807,7 +942,7 @@ Item { anchors.verticalCenter: parent.verticalCenter iconName: "keyboard" buttonSize: 32 - visible: Greetd.state === GreetdState.Inactive && !GreeterState.unlocking + visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking enabled: visible onClicked: { if (keyboard_controller.isKeyboardActive) { @@ -826,7 +961,7 @@ Item { anchors.verticalCenter: parent.verticalCenter iconName: "keyboard_return" buttonSize: 36 - visible: Greetd.state === GreetdState.Inactive && !GreeterState.unlocking + visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking enabled: true onClicked: { if (GreeterState.showPasswordInput) { @@ -920,6 +1055,7 @@ Item { enabled: !GreeterState.unlocking && Greetd.state === GreetdState.Inactive && GreeterState.showPasswordInput onClicked: { GreeterState.reset(); + root.externalAuthAutoStartedForUser = ""; inputField.text = ""; PortalService.profileImage = ""; } @@ -1418,28 +1554,45 @@ Item { enabled: isPrimaryScreen function onAuthMessage(message, error, responseRequired, echoResponse) { - awaitingExternalAuth = root.isExternalAuthPrompt(message, responseRequired); - authTimeout.interval = awaitingExternalAuth ? externalAuthTimeoutMs : defaultAuthTimeoutMs; - authTimeout.restart(); if (responseRequired) { - Greetd.respond(GreeterState.passwordBuffer); - GreeterState.passwordBuffer = ""; - inputField.text = ""; + cancelingExternalAuthForPassword = false; + awaitingExternalAuth = false; + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.restart(); + pendingPasswordResponse = true; + if (passwordSubmitRequested && !root.submitBufferedPassword()) + passwordSubmitRequested = false; return; } + pendingPasswordResponse = false; + if (!passwordSubmitRequested) + awaitingExternalAuth = root.isExternalAuthPrompt(message, responseRequired); + authTimeout.interval = awaitingExternalAuth ? externalAuthTimeoutMs : defaultAuthTimeoutMs; + authTimeout.restart(); Greetd.respond(""); } function onStateChanged() { if (Greetd.state === GreetdState.Inactive) { + const resumePasswordSubmit = cancelingExternalAuthForPassword && passwordSubmitRequested && GreeterState.passwordBuffer && GreeterState.passwordBuffer.length > 0; awaitingExternalAuth = false; + pendingPasswordResponse = false; + cancelingExternalAuthForPassword = false; authTimeout.interval = defaultAuthTimeoutMs; authTimeout.stop(); + if (resumePasswordSubmit) { + Qt.callLater(root.startAuthSession); + return; + } + passwordSubmitRequested = false; } } function onReadyToLaunch() { awaitingExternalAuth = false; + pendingPasswordResponse = false; + passwordSubmitRequested = false; + cancelingExternalAuthForPassword = false; authTimeout.interval = defaultAuthTimeoutMs; authTimeout.stop(); passwordFailureCount = 0; @@ -1470,6 +1623,9 @@ Item { function onAuthFailure(message) { awaitingExternalAuth = false; + pendingPasswordResponse = false; + passwordSubmitRequested = false; + cancelingExternalAuthForPassword = false; authTimeout.interval = defaultAuthTimeoutMs; authTimeout.stop(); launchTimeout.stop(); @@ -1489,6 +1645,9 @@ Item { function onError(error) { awaitingExternalAuth = false; + pendingPasswordResponse = false; + passwordSubmitRequested = false; + cancelingExternalAuthForPassword = false; authTimeout.interval = defaultAuthTimeoutMs; authTimeout.stop(); launchTimeout.stop(); @@ -1509,6 +1668,9 @@ Item { if (GreeterState.unlocking || Greetd.state === GreetdState.Inactive) return; awaitingExternalAuth = false; + pendingPasswordResponse = false; + passwordSubmitRequested = false; + cancelingExternalAuthForPassword = false; authTimeout.interval = defaultAuthTimeoutMs; GreeterState.pamState = "error"; authFeedbackMessage = currentAuthMessage(); @@ -1525,6 +1687,9 @@ Item { onTriggered: { if (!GreeterState.unlocking) return; + pendingPasswordResponse = false; + passwordSubmitRequested = false; + cancelingExternalAuthForPassword = false; GreeterState.unlocking = false; GreeterState.pamState = "error"; authFeedbackMessage = currentAuthMessage(); diff --git a/quickshell/Modules/Greetd/assets/dms-greeter b/quickshell/Modules/Greetd/assets/dms-greeter index dfaf6695..a6091d00 100755 --- a/quickshell/Modules/Greetd/assets/dms-greeter +++ b/quickshell/Modules/Greetd/assets/dms-greeter @@ -204,6 +204,14 @@ if [[ -n "$REMEMBER_LAST_USER" ]]; then 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" +export XDG_CACHE_HOME="$CACHE_DIR/.cache" # Keep greeter VT clean by default; callers can override via env or --debug. if [[ -z "${RUST_LOG:-}" ]]; then diff --git a/quickshell/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml index 429f5242..2ef89d69 100644 --- a/quickshell/Modules/Lock/Pam.qml +++ b/quickshell/Modules/Lock/Pam.qml @@ -215,7 +215,7 @@ Scope { Process { id: availProc - command: ["sh", "-c", "fprintd-list $USER"] + command: ["sh", "-c", "fprintd-list \"${USER:-$(id -un)}\""] onExited: code => { fprint.available = code === 0; fprint.checkAvail(); diff --git a/quickshell/Modules/Settings/GreeterTab.qml b/quickshell/Modules/Settings/GreeterTab.qml new file mode 100644 index 00000000..a220682e --- /dev/null +++ b/quickshell/Modules/Settings/GreeterTab.qml @@ -0,0 +1,587 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common +import qs.Modals.FileBrowser +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets + +Item { + id: root + + FileBrowserModal { + id: greeterWallpaperBrowserModal + browserTitle: I18n.tr("Select greeter background image") + browserIcon: "wallpaper" + browserType: "wallpaper" + showHiddenFiles: true + fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif"] + onFileSelected: path => { + SettingsData.set("greeterWallpaperPath", path); + close(); + } + } + + property string greeterStatusText: "" + property bool greeterStatusRunning: false + property bool greeterSyncRunning: false + property string greeterStatusStdout: "" + property string greeterStatusStderr: "" + property string greeterSyncStdout: "" + property string greeterSyncStderr: "" + property string greeterSudoProbeStderr: "" + property string greeterTerminalFallbackStderr: "" + property bool greeterTerminalFallbackFromPrecheck: false + property var cachedFontFamilies: [] + property bool fontsEnumerated: false + + function runGreeterStatus() { + greeterStatusText = ""; + greeterStatusStdout = ""; + greeterStatusStderr = ""; + greeterStatusRunning = true; + greeterStatusProcess.running = true; + } + + function runGreeterSync() { + greeterSyncStdout = ""; + greeterSyncStderr = ""; + greeterSudoProbeStderr = ""; + greeterTerminalFallbackStderr = ""; + greeterTerminalFallbackFromPrecheck = false; + greeterStatusText = I18n.tr("Checking whether sudo authentication is needed…"); + greeterSyncRunning = true; + greeterSudoProbeProcess.running = true; + } + + function launchGreeterSyncTerminalFallback(fromPrecheck, statusText) { + greeterTerminalFallbackFromPrecheck = fromPrecheck; + if (statusText && statusText !== "") + greeterStatusText = statusText; + greeterTerminalFallbackStderr = ""; + greeterTerminalFallbackProcess.running = true; + } + + function enumerateFonts() { + if (fontsEnumerated) + return; + var fonts = []; + var availableFonts = Qt.fontFamilies(); + for (var i = 0; i < availableFonts.length; i++) { + var fontName = availableFonts[i]; + if (fontName.startsWith(".")) + continue; + fonts.push(fontName); + } + fonts.sort(); + fonts.unshift("Default"); + cachedFontFamilies = fonts; + fontsEnumerated = true; + } + + Component.onCompleted: Qt.callLater(enumerateFonts) + + Process { + id: greeterStatusProcess + command: ["dms", "greeter", "status"] + running: false + + stdout: StdioCollector { + onStreamFinished: { + root.greeterStatusStdout = text || ""; + } + } + + stderr: StdioCollector { + onStreamFinished: root.greeterStatusStderr = text || "" + } + + onExited: exitCode => { + root.greeterStatusRunning = false; + const out = (root.greeterStatusStdout || "").trim(); + const err = (root.greeterStatusStderr || "").trim(); + if (exitCode === 0) { + root.greeterStatusText = out !== "" ? out : I18n.tr("No status output."); + if (err !== "") + root.greeterStatusText = root.greeterStatusText + "\n\nstderr:\n" + err; + return; + } + var failure = I18n.tr("Failed to run 'dms greeter status'. Ensure DMS is installed and dms is in PATH.", "greeter status error") + " (exit " + exitCode + ")"; + if (out !== "") + failure = failure + "\n\n" + out; + if (err !== "") + failure = failure + "\n\nstderr:\n" + err; + root.greeterStatusText = failure; + } + } + + Process { + id: greeterSyncProcess + command: ["dms", "greeter", "sync", "--yes"] + running: false + + stdout: StdioCollector { + onStreamFinished: root.greeterSyncStdout = text || "" + } + + stderr: StdioCollector { + onStreamFinished: root.greeterSyncStderr = text || "" + } + + onExited: exitCode => { + root.greeterSyncRunning = false; + const out = (root.greeterSyncStdout || "").trim(); + const err = (root.greeterSyncStderr || "").trim(); + if (exitCode === 0) { + var success = I18n.tr("Sync completed successfully."); + if (out !== "") + success = success + "\n\n" + out; + if (err !== "") + success = success + "\n\nstderr:\n" + err; + root.greeterStatusText = success; + } else { + var failure = I18n.tr("Sync failed in background mode. Trying terminal mode so you can authenticate interactively.") + " (exit " + exitCode + ")"; + if (out !== "") + failure = failure + "\n\n" + out; + if (err !== "") + failure = failure + "\n\nstderr:\n" + err; + root.greeterStatusText = failure; + root.launchGreeterSyncTerminalFallback(false, ""); + } + } + } + + Process { + id: greeterSudoProbeProcess + command: ["sudo", "-n", "true"] + running: false + + stderr: StdioCollector { + onStreamFinished: root.greeterSudoProbeStderr = text || "" + } + + onExited: exitCode => { + const err = (root.greeterSudoProbeStderr || "").trim(); + if (exitCode === 0) { + root.greeterStatusText = I18n.tr("Running greeter sync…"); + greeterSyncProcess.running = true; + return; + } + + var authNeeded = I18n.tr("Sync needs sudo authentication. Opening terminal so you can use password or fingerprint."); + if (err !== "") + authNeeded = authNeeded + "\n\n" + err; + root.launchGreeterSyncTerminalFallback(true, authNeeded); + } + } + + Process { + id: greeterTerminalFallbackProcess + command: ["dms", "greeter", "sync", "--terminal", "--yes"] + running: false + + stderr: StdioCollector { + onStreamFinished: root.greeterTerminalFallbackStderr = text || "" + } + + onExited: exitCode => { + root.greeterSyncRunning = false; + if (exitCode === 0) { + var launched = root.greeterTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete sync authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete sync there; it will close automatically when done."); + root.greeterStatusText = root.greeterStatusText ? root.greeterStatusText + "\n\n" + launched : launched; + return; + } + var fallback = I18n.tr("Terminal fallback failed. Install one of the supported terminal emulators or run 'dms greeter sync' manually.") + " (exit " + exitCode + ")"; + const err = (root.greeterTerminalFallbackStderr || "").trim(); + if (err !== "") + fallback = fallback + "\n\nstderr:\n" + err; + root.greeterStatusText = root.greeterStatusText ? root.greeterStatusText + "\n\n" + fallback : fallback; + } + } + + readonly property var _lockDateFormatPresets: [ + { + format: "", + label: I18n.tr("System Default", "date format option") + }, + { + format: "ddd d", + label: I18n.tr("Day Date", "date format option") + }, + { + format: "ddd MMM d", + label: I18n.tr("Day Month Date", "date format option") + }, + { + format: "MMM d", + label: I18n.tr("Month Date", "date format option") + }, + { + format: "M/d", + label: I18n.tr("Numeric (M/D)", "date format option") + }, + { + format: "d/M", + label: I18n.tr("Numeric (D/M)", "date format option") + }, + { + format: "ddd d MMM yyyy", + label: I18n.tr("Full with Year", "date format option") + }, + { + format: "yyyy-MM-dd", + label: I18n.tr("ISO Date", "date format option") + }, + { + format: "dddd, MMMM d", + label: I18n.tr("Full Day & Month", "date format option") + } + ] + readonly property var _wallpaperFillModes: ["Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"] + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + topPadding: 4 + width: Math.min(550, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + + SettingsCard { + width: parent.width + iconName: "info" + title: I18n.tr("Greeter Status") + settingKey: "greeterStatus" + + StyledText { + text: I18n.tr("Check sync status on demand. Sync copies your theme, settings, PAM config, and wallpaper to the login screen in one step. Must run Sync to apply changes.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.Wrap + } + + Item { + width: 1 + height: Theme.spacingS + } + + Rectangle { + width: parent.width + height: Math.min(180, statusTextArea.implicitHeight + Theme.spacingM * 2) + radius: Theme.cornerRadius + color: Theme.surfaceContainerHighest + + StyledText { + id: statusTextArea + anchors.fill: parent + anchors.margins: Theme.spacingM + text: root.greeterStatusRunning ? I18n.tr("Checking…", "greeter status loading") : (root.greeterStatusText || I18n.tr("Click Refresh to check status.", "greeter status placeholder")) + font.pixelSize: Theme.fontSizeSmall + font.family: "monospace" + color: root.greeterStatusRunning ? Theme.surfaceVariantText : Theme.surfaceText + wrapMode: Text.Wrap + verticalAlignment: Text.AlignTop + } + } + + Row { + width: parent.width + spacing: Theme.spacingS + topPadding: Theme.spacingM + + DankButton { + text: I18n.tr("Refresh") + iconName: "refresh" + onClicked: root.runGreeterStatus() + enabled: !root.greeterStatusRunning + } + + DankButton { + text: I18n.tr("Sync") + iconName: "sync" + onClicked: root.runGreeterSync() + enabled: !root.greeterSyncRunning + } + } + } + + SettingsCard { + width: parent.width + iconName: "fingerprint" + title: I18n.tr("Login Authentication") + settingKey: "greeterAuth" + + StyledText { + text: I18n.tr("Enable fingerprint or security key for DMS Greeter. Run Sync to apply and configure PAM.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.Wrap + } + + SettingsToggleRow { + settingKey: "greeterEnableFprint" + tags: ["greeter", "fingerprint", "fprintd", "login", "auth"] + text: I18n.tr("Enable fingerprint at login") + description: { + if (!SettingsData.fprintdAvailable) + return I18n.tr("Not available — install fprintd and enroll fingerprints."); + return SettingsData.greeterEnableFprint ? I18n.tr("Run Sync to apply. Fingerprint-only login may not unlock GNOME Keyring.") : I18n.tr("Only off for DMS-managed PAM lines. If greetd includes system-auth/common-auth/password-auth with pam_fprintd, fingerprint still stays enabled."); + } + descriptionColor: SettingsData.fprintdAvailable ? Theme.surfaceVariantText : Theme.warning + checked: SettingsData.greeterEnableFprint + enabled: SettingsData.fprintdAvailable + onToggled: checked => SettingsData.set("greeterEnableFprint", checked) + } + + SettingsToggleRow { + settingKey: "greeterEnableU2f" + tags: ["greeter", "u2f", "security", "key", "login", "auth"] + text: I18n.tr("Enable security key at login") + description: { + if (!SettingsData.u2fAvailable) + return I18n.tr("Not available — install pam_u2f and enroll keys."); + return SettingsData.greeterEnableU2f ? I18n.tr("Run Sync to apply.") : I18n.tr("Disabled."); + } + descriptionColor: SettingsData.u2fAvailable ? Theme.surfaceVariantText : Theme.warning + checked: SettingsData.greeterEnableU2f + enabled: SettingsData.u2fAvailable + onToggled: checked => SettingsData.set("greeterEnableU2f", checked) + } + } + + SettingsCard { + width: parent.width + iconName: "palette" + title: I18n.tr("Greeter Appearance") + settingKey: "greeterAppearance" + + StyledText { + text: I18n.tr("Font") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + topPadding: Theme.spacingM + } + + SettingsDropdownRow { + settingKey: "greeterFontFamily" + tags: ["greeter", "font", "typography"] + text: I18n.tr("Greeter font") + description: I18n.tr("Font used on the login screen") + options: root.fontsEnumerated ? root.cachedFontFamilies : ["Default"] + currentValue: (!SettingsData.greeterFontFamily || SettingsData.greeterFontFamily === "" || SettingsData.greeterFontFamily === Theme.defaultFontFamily) ? "Default" : (SettingsData.greeterFontFamily || "Default") + enableFuzzySearch: true + popupWidthOffset: 100 + maxPopupHeight: 400 + onValueChanged: value => { + if (value === "Default") + SettingsData.set("greeterFontFamily", ""); + else + SettingsData.set("greeterFontFamily", value); + } + } + + StyledText { + text: I18n.tr("Time format") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + topPadding: Theme.spacingM + } + + SettingsToggleRow { + settingKey: "greeterUse24Hour" + tags: ["greeter", "time", "24hour"] + text: I18n.tr("24-hour clock") + description: I18n.tr("Greeter only — does not affect main clock") + checked: SettingsData.greeterUse24HourClock + onToggled: checked => SettingsData.set("greeterUse24HourClock", checked) + } + + SettingsToggleRow { + settingKey: "greeterShowSeconds" + tags: ["greeter", "time", "seconds"] + text: I18n.tr("Show seconds") + checked: SettingsData.greeterShowSeconds + onToggled: checked => SettingsData.set("greeterShowSeconds", checked) + } + + SettingsToggleRow { + settingKey: "greeterPadHours" + tags: ["greeter", "time", "12hour"] + text: I18n.tr("Pad hours (02:00 vs 2:00)") + visible: !SettingsData.greeterUse24HourClock + checked: SettingsData.greeterPadHours12Hour + onToggled: checked => SettingsData.set("greeterPadHours12Hour", checked) + } + + StyledText { + text: I18n.tr("Date format on greeter") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + topPadding: Theme.spacingM + } + + SettingsDropdownRow { + settingKey: "greeterLockDateFormat" + tags: ["greeter", "date", "format"] + text: I18n.tr("Date format") + description: I18n.tr("Greeter only — format for the date on the login screen") + options: root._lockDateFormatPresets.map(p => p.label) + currentValue: { + var current = (SettingsData.greeterLockDateFormat !== undefined && SettingsData.greeterLockDateFormat !== "") ? SettingsData.greeterLockDateFormat : SettingsData.lockDateFormat || ""; + var match = root._lockDateFormatPresets.find(p => p.format === current); + return match ? match.label : (current ? I18n.tr("Custom: ") + current : root._lockDateFormatPresets[0].label); + } + onValueChanged: value => { + var preset = root._lockDateFormatPresets.find(p => p.label === value); + SettingsData.set("greeterLockDateFormat", preset ? preset.format : ""); + } + } + + StyledText { + text: I18n.tr("Background") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + topPadding: Theme.spacingM + } + + StyledText { + text: I18n.tr("Use a custom image for the login screen, or leave empty to use your desktop wallpaper.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.Wrap + } + + Row { + width: parent.width + spacing: Theme.spacingS + + DankTextField { + id: greeterWallpaperPathField + width: parent.width - browseGreeterWallpaperButton.width - Theme.spacingS + placeholderText: I18n.tr("Use desktop wallpaper") + text: SettingsData.greeterWallpaperPath + backgroundColor: Theme.surfaceContainerHighest + onTextChanged: { + if (text !== SettingsData.greeterWallpaperPath) + SettingsData.set("greeterWallpaperPath", text); + } + } + + DankButton { + id: browseGreeterWallpaperButton + text: I18n.tr("Browse") + onClicked: greeterWallpaperBrowserModal.open() + } + } + + SettingsDropdownRow { + settingKey: "greeterWallpaperFillMode" + tags: ["greeter", "wallpaper", "background", "fill"] + text: I18n.tr("Wallpaper fill mode") + description: I18n.tr("How the background image is scaled") + options: root._wallpaperFillModes.map(m => I18n.tr(m, "wallpaper fill mode")) + currentValue: { + var mode = (SettingsData.greeterWallpaperFillMode && SettingsData.greeterWallpaperFillMode !== "") ? SettingsData.greeterWallpaperFillMode : (SettingsData.wallpaperFillMode || "Fill"); + var idx = root._wallpaperFillModes.indexOf(mode); + return idx >= 0 ? I18n.tr(root._wallpaperFillModes[idx], "wallpaper fill mode") : I18n.tr("Fill", "wallpaper fill mode"); + } + onValueChanged: value => { + var idx = root._wallpaperFillModes.map(m => I18n.tr(m, "wallpaper fill mode")).indexOf(value); + if (idx >= 0) + SettingsData.set("greeterWallpaperFillMode", root._wallpaperFillModes[idx]); + } + } + + StyledText { + text: I18n.tr("Layout and module positions on the greeter are synced from your shell (e.g. bar config). Run Sync to apply.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.Wrap + topPadding: Theme.spacingS + } + } + + SettingsCard { + width: parent.width + iconName: "history" + title: I18n.tr("Greeter Behavior") + settingKey: "greeterBehavior" + + StyledText { + text: I18n.tr("Convenience options for the login screen. Sync to apply.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.Wrap + } + + SettingsToggleRow { + settingKey: "greeterRememberLastSession" + tags: ["greeter", "session", "remember", "login"] + text: I18n.tr("Remember last session") + description: I18n.tr("Pre-select the last used session on the greeter") + checked: SettingsData.greeterRememberLastSession + onToggled: checked => SettingsData.set("greeterRememberLastSession", checked) + } + + SettingsToggleRow { + settingKey: "greeterRememberLastUser" + tags: ["greeter", "user", "remember", "login", "username"] + text: I18n.tr("Remember last user") + description: I18n.tr("Pre-fill the last successful username on the greeter") + checked: SettingsData.greeterRememberLastUser + onToggled: checked => SettingsData.set("greeterRememberLastUser", checked) + } + } + + SettingsCard { + width: parent.width + iconName: "extension" + title: I18n.tr("Dependencies & documentation") + settingKey: "greeterDeps" + + StyledText { + text: I18n.tr("DMS greeter needs: greetd, dms-greeter. Fingerprint: fprintd, pam_fprintd. Security keys: pam_u2f. Add your user to the greeter group. Sync checks sudo first and opens a terminal when interactive authentication is required.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.Wrap + } + + StyledText { + text: I18n.tr("Installation and PAM setup: see the ") + "DankGreeter docs " + I18n.tr("or run ") + "'dms greeter install'." + textFormat: Text.RichText + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + linkColor: Theme.primary + width: parent.width + wrapMode: Text.Wrap + onLinkActivated: url => Qt.openUrlExternally(url) + + MouseArea { + anchors.fill: parent + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.NoButton + propagateComposedEvents: true + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/LockScreenTab.qml b/quickshell/Modules/Settings/LockScreenTab.qml index 98584555..44943bd3 100644 --- a/quickshell/Modules/Settings/LockScreenTab.qml +++ b/quickshell/Modules/Settings/LockScreenTab.qml @@ -166,6 +166,114 @@ Item { visible: SettingsData.fprintdAvailable onToggled: checked => SettingsData.set("enableFprint", checked) } + + SettingsToggleRow { + settingKey: "enableU2f" + tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "fido", "authentication", "hardware"] + text: I18n.tr("Enable security key authentication", "Enable FIDO2/U2F hardware security key for lock screen") + description: SettingsData.u2fAvailable ? I18n.tr("Use a FIDO2/U2F security key (e.g. YubiKey) for lock screen authentication (requires enrolled keys)", "lock screen U2F security key setting") : I18n.tr("Not enrolled", "security key not detected status") + descriptionColor: SettingsData.u2fAvailable ? Theme.surfaceVariantText : Theme.warning + checked: SettingsData.enableU2f + enabled: SettingsData.u2fAvailable + onToggled: checked => SettingsData.set("enableU2f", checked) + } + + SettingsDropdownRow { + settingKey: "u2fMode" + tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "mode", "factor", "second"] + text: I18n.tr("Security key mode", "lock screen U2F security key mode setting") + description: I18n.tr("'Alternative' lets the key unlock on its own. 'Second factor' requires password or fingerprint first, then the key.", "lock screen U2F security key mode setting") + visible: SettingsData.u2fAvailable && SettingsData.enableU2f + options: [I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method"), I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint")] + currentValue: SettingsData.u2fMode === "and" ? I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint") : I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method") + onValueChanged: value => { + if (value === I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint")) + SettingsData.set("u2fMode", "and"); + else + SettingsData.set("u2fMode", "or"); + } + } + } + + SettingsCard { + width: parent.width + iconName: "movie" + title: I18n.tr("Video Screensaver") + settingKey: "videoScreensaver" + + StyledText { + visible: !MultimediaService.available + text: I18n.tr("QtMultimedia is not available - video screensaver requires qt multimedia services") + font.pixelSize: Theme.fontSizeSmall + color: Theme.warning + width: parent.width + wrapMode: Text.WordWrap + } + + SettingsToggleRow { + settingKey: "lockScreenVideoEnabled" + tags: ["lock", "screen", "video", "screensaver", "animation", "movie"] + text: I18n.tr("Enable Video Screensaver") + description: I18n.tr("Play a video when the screen locks.") + enabled: MultimediaService.available + checked: SettingsData.lockScreenVideoEnabled + onToggled: checked => SettingsData.set("lockScreenVideoEnabled", checked) + } + + Column { + width: parent.width + spacing: Theme.spacingXS + visible: SettingsData.lockScreenVideoEnabled && MultimediaService.available + + StyledText { + text: I18n.tr("Video Path") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + text: I18n.tr("Path to a video file or folder containing videos") + font.pixelSize: Theme.fontSizeSmall + color: Theme.outlineVariant + wrapMode: Text.WordWrap + width: parent.width + } + + Row { + width: parent.width + spacing: Theme.spacingS + + DankTextField { + id: videoPathField + width: parent.width - browseVideoButton.width - Theme.spacingS + placeholderText: I18n.tr("/path/to/videos") + text: SettingsData.lockScreenVideoPath + backgroundColor: Theme.surfaceContainerHighest + onTextChanged: { + if (text !== SettingsData.lockScreenVideoPath) { + SettingsData.set("lockScreenVideoPath", text); + } + } + } + + DankButton { + id: browseVideoButton + text: I18n.tr("Browse") + onClicked: videoBrowserModal.open() + } + } + } + + SettingsToggleRow { + settingKey: "lockScreenVideoCycling" + tags: ["lock", "screen", "video", "screensaver", "cycling", "random", "shuffle"] + text: I18n.tr("Automatic Cycling") + description: I18n.tr("Pick a different random video each time from the same folder") + visible: SettingsData.lockScreenVideoEnabled && MultimediaService.available + enabled: MultimediaService.available + checked: SettingsData.lockScreenVideoCycling + onToggled: checked => SettingsData.set("lockScreenVideoCycling", checked) + } } SettingsCard { diff --git a/quickshell/assets/pam/fprint b/quickshell/assets/pam/fprint index d4814e94..770a8a4f 100644 --- a/quickshell/assets/pam/fprint +++ b/quickshell/assets/pam/fprint @@ -1,3 +1,3 @@ #%PAM-1.0 -auth required pam_fprintd.so max-tries=1 +auth required pam_fprintd.so max-tries=1 timeout=5