diff --git a/core/cmd/dms/commands_greeter.go b/core/cmd/dms/commands_greeter.go index a2e142de..b16f08f5 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "encoding/json" "fmt" "os" "os/exec" @@ -74,13 +75,20 @@ var greeterSyncCmd = &cobra.Command{ auth, _ := cmd.Flags().GetBool("auth") local, _ := cmd.Flags().GetBool("local") profile, _ := cmd.Flags().GetBool("profile") + autologinOnly, _ := cmd.Flags().GetBool("autologin-only") term, _ := cmd.Flags().GetBool("terminal") if term { - if err := syncInTerminal(yes, auth, local, profile); err != nil { + if err := syncInTerminal(yes, auth, local, profile, autologinOnly); err != nil { log.Fatalf("Error launching sync in terminal: %v", err) } return } + if autologinOnly { + if err := syncGreeterAutoLoginOnly(yes); err != nil { + log.Fatalf("Error syncing greeter auto-login: %v", err) + } + return + } if err := syncGreeter(yes, auth, local, profile); err != nil { log.Fatalf("Error syncing greeter: %v", err) } @@ -93,6 +101,7 @@ func init() { greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles") greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path") greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)") + greeterSyncCmd.Flags().Bool("autologin-only", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)") } var greeterEnableCmd = &cobra.Command{ @@ -520,8 +529,8 @@ func runCommandInTerminal(shellCmd string) error { return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)") } -func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error { - syncFlags := make([]string, 0, 4) +func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool, autologinOnly bool) error { + syncFlags := make([]string, 0, 5) if nonInteractive { syncFlags = append(syncFlags, "--yes") } @@ -534,11 +543,19 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly if profileOnly { syncFlags = append(syncFlags, "--profile") } + if autologinOnly { + syncFlags = append(syncFlags, "--autologin-only") + } shellSyncCmd := "dms greeter sync" if len(syncFlags) > 0 { shellSyncCmd += " " + strings.Join(syncFlags, " ") } - shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3` + var shellCmd string + if autologinOnly { + shellCmd = shellSyncCmd + `; echo; echo "Auto-login update finished. Closing in 3 seconds..."; sleep 3` + } else { + shellCmd = shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3` + } return runCommandInTerminal(shellCmd) } @@ -552,6 +569,49 @@ func resolveLocalWrapperShell() (string, error) { return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper") } +func syncGreeterAutoLoginOnly(nonInteractive bool) error { + logFunc := func(msg string) { + fmt.Println(msg) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") + cacheSettingsPath := filepath.Join(greeter.GreeterCacheDir, "settings.json") + enabled := false + for _, path := range []string{cacheSettingsPath, settingsPath} { + data, readErr := os.ReadFile(path) + if readErr != nil { + continue + } + var cfg struct { + GreeterAutoLogin bool `json:"greeterAutoLogin"` + } + if json.Unmarshal(data, &cfg) == nil { + enabled = cfg.GreeterAutoLogin + break + } + } + + fmt.Println("=== Greeter Auto-Login ===") + fmt.Println() + if enabled { + fmt.Println("Enabling auto-login on startup in greetd.") + fmt.Println("After your next reboot, DMS will skip the greeter password until you sign out.") + } else { + fmt.Println("Disabling auto-login on startup in greetd.") + fmt.Println("After your next reboot, you will enter your password at the greeter again.") + } + fmt.Println() + fmt.Println("Administrator (sudo) access is required to update /etc/greetd/config.toml.") + fmt.Println() + + return greeter.SyncGreeterAutoLoginOnly(logFunc, "") +} + func syncGreeter(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error { if profileOnly { return syncGreeterProfileOnly(nonInteractive) diff --git a/core/internal/greeter/installer.go b/core/internal/greeter/installer.go index bc892590..507f2d67 100644 --- a/core/internal/greeter/installer.go +++ b/core/internal/greeter/installer.go @@ -192,6 +192,421 @@ func upsertDefaultSession(configContent, greeterUser, command string) string { return strings.Join(out, "\n") } +func removeTomlSection(configContent, sectionName string) string { + lines := strings.Split(configContent, "\n") + var out []string + inSection := false + + for _, line := range lines { + if section, ok := parseTomlSection(line); ok { + inSection = section == sectionName + if inSection { + continue + } + out = append(out, line) + continue + } + + if inSection { + continue + } + + out = append(out, line) + } + + result := strings.TrimRight(strings.Join(out, "\n"), "\n") + if result != "" { + result += "\n" + } + return result +} + +func stripDesktopExecCodes(execLine string) string { + fields := strings.Fields(execLine) + cleaned := make([]string, 0, len(fields)) + for _, field := range fields { + if strings.HasPrefix(field, "%") { + continue + } + cleaned = append(cleaned, field) + } + return strings.Join(cleaned, " ") +} + +func formatInitialSessionCommand(sessionExec string) string { + execLine := strings.TrimSpace(stripDesktopExecCodes(sessionExec)) + if execLine == "" { + return `command = ""` + } + escaped := strings.ReplaceAll(execLine, `'`, `'\''`) + inner := fmt.Sprintf("env XDG_SESSION_TYPE=wayland sh -c 'exec %s'", escaped) + tomlEscaped := strings.ReplaceAll(inner, `\`, `\\`) + tomlEscaped = strings.ReplaceAll(tomlEscaped, `"`, `\"`) + return fmt.Sprintf(`command = "%s"`, tomlEscaped) +} + +func upsertInitialSession(configContent, loginUser, sessionExec string, enabled bool) string { + if !enabled { + return removeTomlSection(configContent, "initial_session") + } + + commandLine := formatInitialSessionCommand(sessionExec) + lines := strings.Split(configContent, "\n") + var out []string + + inInitialSession := false + foundInitialSession := false + initialSessionUserSet := false + initialSessionCommandSet := false + + appendInitialSessionFields := func() { + if !initialSessionUserSet { + out = append(out, fmt.Sprintf(`user = "%s"`, loginUser)) + } + if !initialSessionCommandSet { + out = append(out, commandLine) + } + } + + for _, line := range lines { + if section, ok := parseTomlSection(line); ok { + if inInitialSession { + appendInitialSessionFields() + } + + inInitialSession = section == "initial_session" + if inInitialSession { + foundInitialSession = true + initialSessionUserSet = false + initialSessionCommandSet = false + } + + out = append(out, line) + continue + } + + if inInitialSession { + trimmed := stripTomlComment(line) + if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") { + out = append(out, fmt.Sprintf(`user = "%s"`, loginUser)) + initialSessionUserSet = true + continue + } + + if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") { + if !initialSessionCommandSet { + out = append(out, commandLine) + initialSessionCommandSet = true + } + continue + } + } + + out = append(out, line) + } + + if inInitialSession { + appendInitialSessionFields() + } + + if !foundInitialSession { + if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { + out = append(out, "") + } + out = append(out, "[initial_session]") + out = append(out, fmt.Sprintf(`user = "%s"`, loginUser)) + out = append(out, commandLine) + } + + return strings.Join(out, "\n") +} + +type greeterAutoLoginConfig struct { + GreeterAutoLogin bool `json:"greeterAutoLogin"` + GreeterRememberLastUser bool `json:"greeterRememberLastUser"` + GreeterRememberLastSession bool `json:"greeterRememberLastSession"` +} + +type greeterAutoLoginMemory struct { + LastSuccessfulUser string `json:"lastSuccessfulUser"` + LastSessionID string `json:"lastSessionId"` + LastSessionExec string `json:"lastSessionExec"` + AutoLoginEnabled bool `json:"autoLoginEnabled"` +} + +func readGreeterAutoLoginConfig(settingsPath string) (greeterAutoLoginConfig, error) { + cfg := greeterAutoLoginConfig{ + GreeterRememberLastUser: true, + GreeterRememberLastSession: true, + } + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return cfg, err + } + if err := json.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err) + } + return cfg, nil +} + +func readGreeterAutoLoginMemory(memoryPath string) (greeterAutoLoginMemory, error) { + var mem greeterAutoLoginMemory + data, err := os.ReadFile(memoryPath) + if err != nil { + if os.IsNotExist(err) { + return mem, nil + } + return mem, err + } + if err := json.Unmarshal(data, &mem); err != nil { + return mem, fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err) + } + return mem, nil +} + +func execFromDesktopFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + for line := range strings.SplitSeq(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "Exec=") { + return strings.TrimSpace(trimmed[len("Exec="):]), nil + } + } + return "", fmt.Errorf("no Exec= line found in %s", path) +} + +func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionExec string, err error) { + settingsPath := filepath.Join(cacheDir, "settings.json") + if _, statErr := os.Stat(settingsPath); statErr != nil { + settingsPath = filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") + } + + cfg, err := readGreeterAutoLoginConfig(settingsPath) + if err != nil { + return false, "", "", err + } + + memoryPath := filepath.Join(cacheDir, ".local/state/memory.json") + mem, err := readGreeterAutoLoginMemory(memoryPath) + if err != nil { + return false, "", "", err + } + + enabled = cfg.GreeterAutoLogin + if !enabled { + return false, "", "", nil + } + + if !cfg.GreeterRememberLastUser || !cfg.GreeterRememberLastSession { + return true, "", "", nil + } + + loginUser = mem.LastSuccessfulUser + if loginUser == "" { + current, userErr := user.Current() + if userErr != nil { + return true, "", "", userErr + } + loginUser = current.Username + } + + sessionExec = mem.LastSessionExec + if sessionExec == "" && mem.LastSessionID != "" { + sessionExec, err = execFromDesktopFile(mem.LastSessionID) + if err != nil { + sessionExec = "" + } + } + + return true, loginUser, sessionExec, nil +} + +func writeGreetdConfig(configPath, content string, logFunc func(string), sudoPassword, successMsg string) error { + 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) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(content); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write temp greetd config: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp greetd config: %w", err) + } + + if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil { + return fmt.Errorf("failed to create /etc/greetd: %w", err) + } + + if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil { + return fmt.Errorf("failed to install greetd config: %w", err) + } + + if logFunc != nil && successMsg != "" { + logFunc(successMsg) + } + return nil +} + +func clearGreeterAutoLoginMemory(memoryPath, sudoPassword string) error { + data, err := readGreeterMemoryFile(memoryPath, sudoPassword) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err) + } + if _, ok := raw["autoLoginEnabled"]; !ok { + return nil + } + delete(raw, "autoLoginEnabled") + encoded, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + if len(encoded) == 0 || string(encoded) == "null" { + encoded = []byte("{}") + } + encoded = append(encoded, '\n') + + if err := os.WriteFile(memoryPath, encoded, 0o644); err == nil { + return nil + } else if !os.IsPermission(err) { + return err + } + + tmpFile, err := os.CreateTemp("", "greeter-memory-*.json") + if err != nil { + return fmt.Errorf("failed to create temp greeter memory file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(encoded); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write temp greeter memory file: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp greeter memory file: %w", err) + } + + greeterUser := DetectGreeterUser() + greeterGroup := DetectGreeterGroup() + owner := greeterUser + ":" + greeterGroup + if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", greeterUser, "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); err != nil { + if fallbackErr := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); fallbackErr != nil { + return fmt.Errorf("failed to install greeter memory file (preferred %s: %w; fallback root:%s: %v)", owner, err, greeterGroup, fallbackErr) + } + } + return nil +} + +func readGreeterMemoryFile(memoryPath, sudoPassword string) ([]byte, error) { + data, err := os.ReadFile(memoryPath) + if err == nil || !os.IsPermission(err) { + return data, err + } + + tmpFile, err := os.CreateTemp("", "greeter-memory-read-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp file for greeter memory read: %w", err) + } + tmpPath := tmpFile.Name() + _ = tmpFile.Close() + defer os.Remove(tmpPath) + + if err := privesc.Run(context.Background(), sudoPassword, "cp", "-f", memoryPath, tmpPath); err != nil { + return nil, fmt.Errorf("failed to read greeter memory at %s: %w", memoryPath, err) + } + return os.ReadFile(tmpPath) +} + +func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPassword string) error { + enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir) + if err != nil { + return err + } + + configPath := "/etc/greetd/config.toml" + configContent := "" + if data, readErr := os.ReadFile(configPath); readErr == nil { + configContent = string(data) + } else if !os.IsNotExist(readErr) { + return fmt.Errorf("failed to read greetd config: %w", readErr) + } + + if !enabled { + memoryPath := filepath.Join(cacheDir, ".local/state/memory.json") + if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err)) + } + newConfig := upsertInitialSession(configContent, "", "", false) + if newConfig == configContent { + if logFunc != nil { + logFunc("✓ Greeter auto-login disabled") + } + return nil + } + return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Disabled greeter auto-login") + } + + if loginUser == "" || sessionExec == "" { + if logFunc != nil { + logFunc("⚠ Greeter auto-login is enabled but user or session is not configured yet. Log in manually once, then run sync.") + } + newConfig := upsertInitialSession(configContent, "", "", false) + if newConfig != configContent { + return writeGreetdConfig(configPath, newConfig, nil, sudoPassword, "") + } + return nil + } + + newConfig := upsertInitialSession(configContent, loginUser, sessionExec, true) + if newConfig == configContent { + if logFunc != nil { + logFunc(fmt.Sprintf("✓ Greeter auto-login already configured for %s", loginUser)) + } + memoryPath := filepath.Join(cacheDir, ".local/state/memory.json") + _ = clearGreeterAutoLoginMemory(memoryPath, sudoPassword) + return nil + } + + if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Configured greeter auto-login for %s", loginUser)); err != nil { + return err + } + memoryPath := filepath.Join(cacheDir, ".local/state/memory.json") + if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err)) + } + return nil +} + +func SyncGreeterAutoLoginOnly(logFunc func(string), sudoPassword string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + return SyncGreetdAutoLogin(GreeterCacheDir, homeDir, logFunc, sudoPassword) +} + func DetectGreeterUser() string { passwdData, err := os.ReadFile("/etc/passwd") if err == nil { @@ -1267,6 +1682,10 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo return fmt.Errorf("per-user greeter cache sync failed: %w", err) } + if err := SyncGreetdAutoLogin(cacheDir, homeDir, logFunc, sudoPassword); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: greeter auto-login sync failed: %v", err)) + } + if strings.ToLower(compositor) != "niri" { return nil } @@ -1731,29 +2150,22 @@ vt = 1 commandLine := fmt.Sprintf(`command = "%s"`, commandValue) newConfig := upsertDefaultSession(configContent, greeterUser, commandLine) - tmpFile, err := os.CreateTemp("", "greetd-config-*.toml") - if err != nil { - return fmt.Errorf("failed to create temp greetd config: %w", err) - } - defer os.Remove(tmpFile.Name()) - - if _, err := tmpFile.WriteString(newConfig); err != nil { - _ = tmpFile.Close() - return fmt.Errorf("failed to write temp greetd config: %w", err) - } - if err := tmpFile.Close(); err != nil { - return fmt.Errorf("failed to close temp greetd config: %w", err) + homeDir, homeErr := os.UserHomeDir() + if homeErr == nil { + enabled, loginUser, sessionExec, resolveErr := resolveGreeterAutoLoginState(GreeterCacheDir, homeDir) + if resolveErr != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to resolve greeter auto-login state: %v", resolveErr)) + } else if enabled && loginUser != "" && sessionExec != "" { + newConfig = upsertInitialSession(newConfig, loginUser, sessionExec, true) + } else { + newConfig = upsertInitialSession(newConfig, "", "", false) + } } - if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil { - return fmt.Errorf("failed to create /etc/greetd: %w", err) + if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue)); err != nil { + return err } - if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil { - return fmt.Errorf("failed to install greetd config: %w", err) - } - - logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue)) return nil } diff --git a/core/internal/greeter/installer_test.go b/core/internal/greeter/installer_test.go index 9acee578..8bc4f77c 100644 --- a/core/internal/greeter/installer_test.go +++ b/core/internal/greeter/installer_test.go @@ -3,6 +3,7 @@ package greeter import ( "os" "path/filepath" + "strings" "testing" ) @@ -96,3 +97,147 @@ func TestResolveGreeterThemeSyncState(t *testing.T) { }) } } + +func TestUpsertInitialSession(t *testing.T) { + t.Parallel() + + baseConfig := `[terminal] +vt = 1 + +[default_session] +user = "greeter" +command = "/usr/bin/dms-greeter --command niri" +` + + t.Run("inserts initial session", func(t *testing.T) { + t.Parallel() + got := upsertInitialSession(baseConfig, "alice", "niri", true) + if !strings.Contains(got, "[initial_session]") { + t.Fatalf("expected [initial_session] section, got:\n%s", got) + } + if !strings.Contains(got, `user = "alice"`) { + t.Fatalf("expected alice user in initial session, got:\n%s", got) + } + if !strings.Contains(got, `env XDG_SESSION_TYPE=wayland sh -c 'exec niri'`) { + t.Fatalf("expected wrapped session command, got:\n%s", got) + } + }) + + t.Run("updates existing initial session", func(t *testing.T) { + t.Parallel() + existing := baseConfig + ` +[initial_session] +user = "bob" +command = "old-command" +` + got := upsertInitialSession(existing, "alice", "Hyprland", true) + if strings.Contains(got, `user = "bob"`) { + t.Fatalf("expected bob to be replaced, got:\n%s", got) + } + if !strings.Contains(got, `exec Hyprland`) { + t.Fatalf("expected Hyprland command, got:\n%s", got) + } + }) + + t.Run("removes initial session when disabled", func(t *testing.T) { + t.Parallel() + existing := baseConfig + ` +[initial_session] +user = "alice" +command = "niri" +` + got := upsertInitialSession(existing, "", "", false) + if strings.Contains(got, "[initial_session]") { + t.Fatalf("expected initial session removed, got:\n%s", got) + } + if !strings.Contains(got, "[default_session]") { + t.Fatalf("expected default session preserved, got:\n%s", got) + } + }) +} + +func TestStripDesktopExecCodes(t *testing.T) { + t.Parallel() + + got := stripDesktopExecCodes("niri --session %f") + want := "niri --session" + if got != want { + t.Fatalf("stripDesktopExecCodes = %q, want %q", got, want) + } +} + +func TestResolveGreeterAutoLoginState(t *testing.T) { + t.Parallel() + + cacheDir := t.TempDir() + homeDir := t.TempDir() + + writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{ + "greeterAutoLogin": true, + "greeterRememberLastUser": true, + "greeterRememberLastSession": true +}`) + writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{ + "lastSuccessfulUser": "alice", + "lastSessionExec": "niri" +}`) + + enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir) + if err != nil { + t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err) + } + if !enabled || loginUser != "alice" || sessionExec != "niri" { + t.Fatalf("got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec) + } +} + +func TestResolveGreeterAutoLoginStateIgnoresMemoryFlag(t *testing.T) { + t.Parallel() + + cacheDir := t.TempDir() + homeDir := t.TempDir() + + writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{ + "greeterAutoLogin": false, + "greeterRememberLastUser": true, + "greeterRememberLastSession": true +}`) + writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{ + "autoLoginEnabled": true, + "lastSuccessfulUser": "alice", + "lastSessionExec": "niri" +}`) + + enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir) + if err != nil { + t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err) + } + if enabled || loginUser != "" || sessionExec != "" { + t.Fatalf("expected disabled with empty user/exec, got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec) + } +} + +func TestClearGreeterAutoLoginMemory(t *testing.T) { + t.Parallel() + + memoryPath := filepath.Join(t.TempDir(), "memory.json") + writeTestFile(t, memoryPath, `{ + "autoLoginEnabled": true, + "lastSuccessfulUser": "alice" +}`) + + if err := clearGreeterAutoLoginMemory(memoryPath, ""); err != nil { + t.Fatalf("clearGreeterAutoLoginMemory returned error: %v", err) + } + + data, err := os.ReadFile(memoryPath) + if err != nil { + t.Fatalf("failed to read memory file: %v", err) + } + if strings.Contains(string(data), "autoLoginEnabled") { + t.Fatalf("expected autoLoginEnabled removed, got: %s", string(data)) + } + if !strings.Contains(string(data), "lastSuccessfulUser") { + t.Fatalf("expected other memory fields preserved, got: %s", string(data)) + } +} diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 14dfe8f5..37d1c430 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -414,6 +414,7 @@ Singleton { property string lockDateFormat: "" property bool greeterRememberLastSession: true property bool greeterRememberLastUser: true + property bool greeterAutoLogin: false property bool greeterEnableFprint: false property bool greeterEnableU2f: false property string greeterWallpaperPath: "" @@ -1333,6 +1334,15 @@ Singleton { }); } + function scheduleGreeterAutoLoginSync() { + if (isGreeterMode) + return; + Qt.callLater(() => { + Processes.settingsRoot = root; + Processes.scheduleGreeterAutoLoginSync(); + }); + } + readonly property var _hooks: ({ "applyStoredTheme": applyStoredTheme, "regenSystemThemes": regenSystemThemes, @@ -1340,7 +1350,8 @@ Singleton { "applyStoredIconTheme": applyStoredIconTheme, "updateBarConfigs": updateBarConfigs, "updateCompositorCursor": updateCompositorCursor, - "scheduleAuthApply": scheduleAuthApply + "scheduleAuthApply": scheduleAuthApply, + "scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync }) function set(key, value) { diff --git a/quickshell/Common/settings/Processes.qml b/quickshell/Common/settings/Processes.qml index a11abae2..e9fcf433 100644 --- a/quickshell/Common/settings/Processes.qml +++ b/quickshell/Common/settings/Processes.qml @@ -12,6 +12,35 @@ Singleton { property var settingsRoot: null + onSettingsRootChanged: { + if (settingsRoot && !settingsRoot.isGreeterMode) + consumeGreeterAutoLoginPendingSync(); + } + + readonly property string greeterAutoLoginPendingSyncPath: (Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending" + + function consumeGreeterAutoLoginPendingSync() { + if (!settingsRoot || settingsRoot.isGreeterMode) + return; + greeterAutoLoginPendingCheckProcess.running = true; + } + + property var greeterAutoLoginPendingCheckProcess: Process { + command: ["sh", "-c", "if [ -f " + JSON.stringify(root.greeterAutoLoginPendingSyncPath) + " ]; then rm -f " + JSON.stringify(root.greeterAutoLoginPendingSyncPath) + "; echo pending; fi"] + running: false + + stdout: StdioCollector { + onStreamFinished: { + if ((text || "").trim() !== "pending" || !root.settingsRoot) + return; + if (!root.settingsRoot.greeterAutoLogin) + root.settingsRoot.set("greeterAutoLogin", true); + else + root.scheduleGreeterAutoLoginSync(); + } + } + } + property string greetdPamText: "" property string systemAuthPamText: "" property string commonAuthPamText: "" @@ -296,6 +325,66 @@ Singleton { authApplyDebounce.restart(); } + // --- Greeter auto-login sync pipeline --- + + property bool greeterAutoLoginSyncRunning: false + property bool greeterAutoLoginSyncQueued: false + property bool greeterAutoLoginSyncRerunRequested: false + property string greeterAutoLoginSyncStdout: "" + property string greeterAutoLoginSyncStderr: "" + property string greeterAutoLoginSyncTerminalFallbackStderr: "" + + function scheduleGreeterAutoLoginSync() { + if (!settingsRoot || settingsRoot.isGreeterMode) + return; + + greeterAutoLoginSyncQueued = true; + if (greeterAutoLoginSyncRunning) { + greeterAutoLoginSyncRerunRequested = true; + return; + } + + greeterAutoLoginSyncDebounce.restart(); + } + + function beginGreeterAutoLoginSync() { + if (!greeterAutoLoginSyncQueued || greeterAutoLoginSyncRunning || !settingsRoot || settingsRoot.isGreeterMode) + return; + + greeterAutoLoginSyncQueued = false; + greeterAutoLoginSyncRerunRequested = false; + greeterAutoLoginSyncStdout = ""; + greeterAutoLoginSyncStderr = ""; + greeterAutoLoginSyncTerminalFallbackStderr = ""; + greeterAutoLoginSyncRunning = true; + greeterAutoLoginSyncSudoProbeProcess.running = true; + } + + function launchGreeterAutoLoginSyncTerminalFallback(details) { + ToastService.showWarning(I18n.tr("Opening terminal to update greetd"), I18n.tr("DMS needs administrator access. The terminal closes automatically when done.") + (details ? "\n\n" + details : ""), "dms greeter sync --autologin-only", "greeter-autologin-sync"); + greeterAutoLoginSyncTerminalFallbackStderr = ""; + greeterAutoLoginSyncTerminalFallbackProcess.running = true; + } + + function greeterAutoLoginSyncSuccessToast(details) { + const enabling = settingsRoot && settingsRoot.greeterAutoLogin; + // Clear the sticky in-progress toast, then confirm with an auto-dismissing toast. + ToastService.dismissCategory("greeter-autologin-sync"); + if (enabling) { + ToastService.showWarning(I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : "")); + } else { + ToastService.showInfo(I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : "")); + } + } + + function finishGreeterAutoLoginSync() { + const shouldRerun = greeterAutoLoginSyncQueued || greeterAutoLoginSyncRerunRequested; + greeterAutoLoginSyncRunning = false; + greeterAutoLoginSyncRerunRequested = false; + if (shouldRerun) + greeterAutoLoginSyncDebounce.restart(); + } + // --- PAM parsing helpers --- function stripPamComment(line) { @@ -433,6 +522,82 @@ Singleton { onTriggered: root.beginAuthApply() } + Timer { + id: greeterAutoLoginSyncDebounce + interval: 300 + repeat: false + onTriggered: root.beginGreeterAutoLoginSync() + } + + property var greeterAutoLoginSyncProcess: Process { + command: ["dms", "greeter", "sync", "--yes", "--autologin-only"] + running: false + + stdout: StdioCollector { + onStreamFinished: root.greeterAutoLoginSyncStdout = text || "" + } + + stderr: StdioCollector { + onStreamFinished: root.greeterAutoLoginSyncStderr = text || "" + } + + onExited: exitCode => { + const out = (root.greeterAutoLoginSyncStdout || "").trim(); + const err = (root.greeterAutoLoginSyncStderr || "").trim(); + + if (exitCode === 0) { + let details = out; + if (err !== "") + details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err; + root.greeterAutoLoginSyncSuccessToast(details); + root.finishGreeterAutoLoginSync(); + return; + } + + let details = ""; + if (out !== "") + details = out; + if (err !== "") + details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err; + root.launchGreeterAutoLoginSyncTerminalFallback(details); + } + } + + property var greeterAutoLoginSyncSudoProbeProcess: Process { + command: ["sudo", "-n", "true"] + running: false + + onExited: exitCode => { + const enabling = root.settingsRoot && root.settingsRoot.greeterAutoLogin; + if (exitCode === 0) { + ToastService.showWarning(enabling ? I18n.tr("Applying auto-login on startup…") : I18n.tr("Disabling auto-login on startup…"), "", "dms greeter sync --autologin-only", "greeter-autologin-sync"); + root.greeterAutoLoginSyncProcess.running = true; + return; + } + + root.launchGreeterAutoLoginSyncTerminalFallback(); + } + } + + property var greeterAutoLoginSyncTerminalFallbackProcess: Process { + command: ["dms", "greeter", "sync", "--terminal", "--yes", "--autologin-only"] + running: false + + stderr: StdioCollector { + onStreamFinished: root.greeterAutoLoginSyncTerminalFallbackStderr = text || "" + } + + onExited: exitCode => { + if (exitCode === 0) { + root.greeterAutoLoginSyncSuccessToast(""); + } else { + let details = (root.greeterAutoLoginSyncTerminalFallbackStderr || "").trim(); + ToastService.showError(I18n.tr("Couldn't open a terminal for the auto-login update.") + " (exit " + exitCode + ")", details, "dms greeter sync --autologin-only", "greeter-autologin-sync"); + } + root.finishGreeterAutoLoginSync(); + } + } + property var authApplyProcess: Process { command: ["dms", "auth", "sync", "--yes"] running: false diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 76d072a3..8d5451ac 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -182,6 +182,7 @@ var SPEC = { lockDateFormat: { def: "" }, greeterRememberLastSession: { def: true }, greeterRememberLastUser: { def: true }, + greeterAutoLogin: { def: false, onChange: "scheduleGreeterAutoLoginSync" }, greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" }, greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" }, greeterWallpaperPath: { def: "" }, diff --git a/quickshell/Modules/Greetd/GreetdMemory.qml b/quickshell/Modules/Greetd/GreetdMemory.qml index b78e04d3..ffd9e47e 100644 --- a/quickshell/Modules/Greetd/GreetdMemory.qml +++ b/quickshell/Modules/Greetd/GreetdMemory.qml @@ -18,6 +18,7 @@ Singleton { readonly property bool rememberLastUser: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_USER", "DMS_SAVE_USERNAME"], true) property string lastSessionId: "" + property string lastSessionExec: "" property string lastSuccessfulUser: "" property bool memoryReady: false property bool isLightMode: false @@ -54,6 +55,7 @@ Singleton { return; const memory = JSON.parse(content); lastSessionId = rememberLastSession ? (memory.lastSessionId || "") : ""; + lastSessionExec = rememberLastSession ? (memory.lastSessionExec || "") : ""; lastSuccessfulUser = rememberLastUser ? (memory.lastSuccessfulUser || "") : ""; if (!rememberLastSession || !rememberLastUser) saveMemory(); @@ -66,6 +68,8 @@ Singleton { let memory = {}; if (rememberLastSession && lastSessionId) memory.lastSessionId = lastSessionId; + if (rememberLastSession && lastSessionExec) + memory.lastSessionExec = lastSessionExec; if (rememberLastUser && lastSuccessfulUser) memory.lastSuccessfulUser = lastSuccessfulUser; memoryFileView.setText(JSON.stringify(memory, null, 2)); @@ -73,13 +77,28 @@ Singleton { function setLastSessionId(id) { if (!rememberLastSession) { - if (lastSessionId !== "") { + if (lastSessionId !== "" || lastSessionExec !== "") { lastSessionId = ""; + lastSessionExec = ""; saveMemory(); } return; } lastSessionId = id || ""; + if (!lastSessionId) + lastSessionExec = ""; + saveMemory(); + } + + function setLastSessionExec(exec) { + if (!rememberLastSession) { + if (lastSessionExec !== "") { + lastSessionExec = ""; + saveMemory(); + } + return; + } + lastSessionExec = exec || ""; saveMemory(); } diff --git a/quickshell/Modules/Greetd/GreetdSettings.qml b/quickshell/Modules/Greetd/GreetdSettings.qml index abfb6580..49e6cb71 100644 --- a/quickshell/Modules/Greetd/GreetdSettings.qml +++ b/quickshell/Modules/Greetd/GreetdSettings.qml @@ -67,6 +67,7 @@ Singleton { property bool lockScreenShowProfileImage: true property bool rememberLastSession: true property bool rememberLastUser: true + property bool greeterAutoLogin: false property bool greeterEnableFprint: false property bool greeterEnableU2f: false property string greeterWallpaperPath: "" @@ -132,6 +133,9 @@ Singleton { } else { rememberLastUser = settings.greeterRememberLastUser !== undefined ? settings.greeterRememberLastUser : settings.rememberLastUser !== undefined ? settings.rememberLastUser : true; } + if (configBaseDir === root._greeterCacheDir) { + greeterAutoLogin = settings.greeterAutoLogin !== undefined ? settings.greeterAutoLogin : false; + } greeterEnableFprint = settings.greeterEnableFprint !== undefined ? settings.greeterEnableFprint : false; greeterEnableU2f = settings.greeterEnableU2f !== undefined ? settings.greeterEnableU2f : false; greeterWallpaperPath = settings.greeterWallpaperPath !== undefined ? settings.greeterWallpaperPath : ""; diff --git a/quickshell/Modules/Greetd/GreeterContent.qml b/quickshell/Modules/Greetd/GreeterContent.qml index d7a22547..7d3c6bbf 100644 --- a/quickshell/Modules/Greetd/GreeterContent.qml +++ b/quickshell/Modules/Greetd/GreeterContent.qml @@ -57,6 +57,7 @@ Item { property int maxPasswordSessionTransitionRetries: 2 property bool fprintdProbeComplete: false property bool fprintdHasDevice: false + property bool autoLoginOnSuccess: false // Falls back to PAM-only detection until the fprintd D-Bus probe completes. readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd") && (!fprintdProbeComplete || fprintdHasDevice) readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f") @@ -524,6 +525,7 @@ Item { passwordFailureCount = 0; clearAuthFeedback(); externalAuthAutoStartedForUser = ""; + root.autoLoginOnSuccess = false; } root.pickerThemeUsername = user; GreeterState.username = user; @@ -646,6 +648,12 @@ Item { } } + Process { + id: greeterAutoLoginPendingProcess + command: ["sh", "-c", "mkdir -p $(dirname " + JSON.stringify((Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending") + ") && touch " + JSON.stringify((Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending")] + running: false + } + Process { id: hyprlandLayoutProcess running: false @@ -870,110 +878,110 @@ Item { anchors.top: parent.top spacing: 0 - property string fullTimeStr: { - const format = GreetdSettings.getEffectiveTimeFormat(); - return systemClock.date.toLocaleTimeString(I18n.locale(), format); - } - property var timeParts: fullTimeStr.split(':') - property string hours: timeParts[0] || "" - property string minutes: timeParts[1] || "" - property string secondsWithAmPm: timeParts.length > 2 ? timeParts[2] : "" - property string seconds: secondsWithAmPm.replace(/\s*(AM|PM|am|pm)$/i, '') - property string ampm: { - const match = fullTimeStr.match(/\s*(AM|PM|am|pm)$/i); - return match ? match[0].trim() : ""; - } - property bool hasSeconds: timeParts.length > 2 + property string fullTimeStr: { + const format = GreetdSettings.getEffectiveTimeFormat(); + return systemClock.date.toLocaleTimeString(I18n.locale(), format); + } + property var timeParts: fullTimeStr.split(':') + property string hours: timeParts[0] || "" + property string minutes: timeParts[1] || "" + property string secondsWithAmPm: timeParts.length > 2 ? timeParts[2] : "" + property string seconds: secondsWithAmPm.replace(/\s*(AM|PM|am|pm)$/i, '') + property string ampm: { + const match = fullTimeStr.match(/\s*(AM|PM|am|pm)$/i); + return match ? match[0].trim() : ""; + } + property bool hasSeconds: timeParts.length > 2 - StyledText { - width: 75 - text: clockText.hours.length > 1 ? clockText.hours[0] : "" - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - horizontalAlignment: Text.AlignHCenter - } + StyledText { + width: 75 + text: clockText.hours.length > 1 ? clockText.hours[0] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + } - StyledText { - width: 75 - text: clockText.hours.length > 1 ? clockText.hours[1] : clockText.hours.length > 0 ? clockText.hours[0] : "" - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - horizontalAlignment: Text.AlignHCenter - } + StyledText { + width: 75 + text: clockText.hours.length > 1 ? clockText.hours[1] : clockText.hours.length > 0 ? clockText.hours[0] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + } - StyledText { - text: ":" - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - } + StyledText { + text: ":" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + } - StyledText { - width: 75 - text: clockText.minutes.length > 0 ? clockText.minutes[0] : "" - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - horizontalAlignment: Text.AlignHCenter - } + StyledText { + width: 75 + text: clockText.minutes.length > 0 ? clockText.minutes[0] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + } - StyledText { - width: 75 - text: clockText.minutes.length > 1 ? clockText.minutes[1] : "" - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - horizontalAlignment: Text.AlignHCenter - } + StyledText { + width: 75 + text: clockText.minutes.length > 1 ? clockText.minutes[1] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + } - StyledText { - text: clockText.hasSeconds ? ":" : "" - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - visible: clockText.hasSeconds - } + StyledText { + text: clockText.hasSeconds ? ":" : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + visible: clockText.hasSeconds + } - StyledText { - width: 75 - text: clockText.hasSeconds && clockText.seconds.length > 0 ? clockText.seconds[0] : "" - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - horizontalAlignment: Text.AlignHCenter - visible: clockText.hasSeconds - } + StyledText { + width: 75 + text: clockText.hasSeconds && clockText.seconds.length > 0 ? clockText.seconds[0] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + visible: clockText.hasSeconds + } - StyledText { - width: 75 - text: clockText.hasSeconds && clockText.seconds.length > 1 ? clockText.seconds[1] : "" - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - horizontalAlignment: Text.AlignHCenter - visible: clockText.hasSeconds - } + StyledText { + width: 75 + text: clockText.hasSeconds && clockText.seconds.length > 1 ? clockText.seconds[1] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + visible: clockText.hasSeconds + } - StyledText { - width: 20 - text: " " - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - visible: clockText.ampm !== "" - } + StyledText { + width: 20 + text: " " + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + visible: clockText.ampm !== "" + } - StyledText { - text: clockText.ampm - font.pixelSize: 120 - font.weight: Font.Light - color: "white" - visible: clockText.ampm !== "" + StyledText { + text: clockText.ampm + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + visible: clockText.ampm !== "" + } } } - } StyledText { id: dateText @@ -1355,7 +1363,7 @@ Item { StyledText { Layout.fillWidth: true - Layout.preferredHeight: 38 + Layout.preferredHeight: root.authFeedbackMessage !== "" ? 38 : 0 Layout.topMargin: -Theme.spacingS Layout.bottomMargin: -Theme.spacingS text: root.authFeedbackMessage @@ -1374,48 +1382,150 @@ Item { } } - Rectangle { - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: 0 - Layout.preferredWidth: switchUserRow.width + Theme.spacingL * 2 - Layout.preferredHeight: 40 - radius: Theme.cornerRadius - color: Theme.surfaceContainer - opacity: GreeterState.showPasswordInput ? 1 : 0 - enabled: GreeterState.showPasswordInput + // Password-screen actions: Switch User + Auto-login toggle as one compact chip row + Item { + id: passwordActions - Behavior on opacity { - NumberAnimation { - duration: Theme.mediumDuration - easing.type: Theme.standardEasing - } - } + readonly property bool autoLoginAvailable: GreetdSettings.rememberLastUser && GreetdSettings.rememberLastSession + + Layout.fillWidth: true + Layout.topMargin: Theme.spacingXS + Layout.preferredHeight: visible ? 32 : 0 + visible: GreeterState.showPasswordInput && !GreeterState.unlocking && (root.multipleUsersAvailable || autoLoginAvailable) Row { - id: switchUserRow anchors.centerIn: parent spacing: Theme.spacingS - DankIcon { - name: "people" - size: Theme.iconSize - 4 - color: Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter + Rectangle { + id: switchUserChip + + visible: root.multipleUsersAvailable + height: 32 + width: switchUserContent.implicitWidth + Theme.spacingM * 2 + radius: height / 2 + color: Theme.withAlpha(Theme.surfaceVariant, 0.65) + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: (switchUserMouse.containsMouse || switchUserMouse.pressed) ? Theme.surfaceTextHover : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.shorterDuration + easing.type: Theme.standardEasing + } + } + } + + DankRipple { + id: switchUserRipple + cornerRadius: switchUserChip.radius + rippleColor: Theme.surfaceVariantText + } + + Row { + id: switchUserContent + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: "people" + size: 16 + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Switch User") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: switchUserMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => switchUserRipple.trigger(mouse.x, mouse.y) + onClicked: root.returnToUserPicker() + } } - StyledText { - text: I18n.tr("Switch User") - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } - } + Rectangle { + id: autoLoginChip - StateLayer { - stateColor: Theme.primary - cornerRadius: parent.radius - enabled: !GreeterState.unlocking && GreeterState.showPasswordInput - onClicked: root.returnToUserPicker() + visible: passwordActions.autoLoginAvailable + height: 32 + width: autoLoginContent.implicitWidth + Theme.spacingM * 2 + radius: height / 2 + color: root.autoLoginOnSuccess ? Theme.withAlpha(Theme.primary, 0.85) : Theme.withAlpha(Theme.surfaceVariant, 0.65) + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: { + if (autoLoginMouse.pressed) + return root.autoLoginOnSuccess ? Theme.primaryPressed : Theme.surfaceTextHover; + if (autoLoginMouse.containsMouse) + return root.autoLoginOnSuccess ? Theme.primaryHover : Theme.surfaceTextHover; + return "transparent"; + } + + Behavior on color { + ColorAnimation { + duration: Theme.shorterDuration + easing.type: Theme.standardEasing + } + } + } + + DankRipple { + id: autoLoginRipple + cornerRadius: autoLoginChip.radius + rippleColor: root.autoLoginOnSuccess ? Theme.primaryText : Theme.surfaceVariantText + } + + Row { + id: autoLoginContent + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: root.autoLoginOnSuccess ? "check" : "login" + size: 16 + color: root.autoLoginOnSuccess ? Theme.primaryText : Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Auto-login") + font.pixelSize: Theme.fontSizeSmall + font.weight: root.autoLoginOnSuccess ? Font.Medium : Font.Normal + color: root.autoLoginOnSuccess ? Theme.primaryText : Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: autoLoginMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.autoLoginOnSuccess = !root.autoLoginOnSuccess + onPressed: mouse => autoLoginRipple.trigger(mouse.x, mouse.y) + } + } } } } @@ -1984,7 +2094,8 @@ Item { launchTimeout.restart(); if (GreetdSettings.rememberLastSession) { GreetdMemory.setLastSessionId(sessionPath); - } else if (GreetdMemory.lastSessionId) { + GreetdMemory.setLastSessionExec(sessionCmd); + } else if (GreetdMemory.lastSessionId || GreetdMemory.lastSessionExec) { GreetdMemory.setLastSessionId(""); } if (GreetdSettings.rememberLastUser) { @@ -1992,6 +2103,8 @@ Item { } else if (GreetdMemory.lastSuccessfulUser) { GreetdMemory.setLastSuccessfulUser(""); } + if (root.autoLoginOnSuccess) + greeterAutoLoginPendingProcess.running = true; pendingLaunchCommand = sessionCmd; pendingLaunchEnv = ["XDG_SESSION_TYPE=wayland"]; memoryFlushTimer.restart(); diff --git a/quickshell/Modules/Settings/GreeterTab.qml b/quickshell/Modules/Settings/GreeterTab.qml index cdd6111d..de97dd2b 100644 --- a/quickshell/Modules/Settings/GreeterTab.qml +++ b/quickshell/Modules/Settings/GreeterTab.qml @@ -743,6 +743,16 @@ Item { checked: SettingsData.greeterRememberLastUser onToggled: checked => SettingsData.set("greeterRememberLastUser", checked) } + + SettingsToggleRow { + settingKey: "greeterAutoLogin" + tags: ["greeter", "autologin", "login", "startup", "password"] + text: I18n.tr("Auto-login on startup") + description: SettingsData.greeterRememberLastUser && SettingsData.greeterRememberLastSession ? I18n.tr("Skip the greeter password after boot until you sign out. Lock screen unlock is unchanged. Takes effect on the next reboot after sync.") : I18n.tr("Requires remembering the last user and session. Enable those options first.") + checked: SettingsData.greeterAutoLogin + enabled: SettingsData.greeterRememberLastUser && SettingsData.greeterRememberLastSession + onToggled: checked => SettingsData.set("greeterAutoLogin", checked) + } } SettingsCard { diff --git a/quickshell/Modules/Toast.qml b/quickshell/Modules/Toast.qml index 82af63f5..e38e47e2 100644 --- a/quickshell/Modules/Toast.qml +++ b/quickshell/Modules/Toast.qml @@ -50,7 +50,7 @@ PanelWindow { WlrLayershell.keyboardFocus: WlrKeyboardFocus.None color: "transparent" - readonly property real toastWidth: shouldBeVisible ? Theme.px(Math.min(900, messageText.implicitWidth + statusIcon.width + Theme.spacingM + (ToastService.hasDetails ? (expandButton.width + closeButton.width + 4) : (ToastService.currentLevel === ToastService.levelError ? closeButton.width + Theme.spacingS : 0)) + Theme.spacingL * 2 + Theme.spacingM * 2), dpr) : frozenWidth + readonly property real toastWidth: shouldBeVisible ? Theme.px(Math.min(900, messageText.implicitWidth + statusIcon.width + Theme.spacingM + ((ToastService.hasDetails || ToastService.isStickyCategory(ToastService.currentCategory)) ? (expandButton.width + closeButton.width + 4) : (ToastService.currentLevel === ToastService.levelError ? closeButton.width + Theme.spacingS : 0)) + Theme.spacingL * 2 + Theme.spacingM * 2), dpr) : frozenWidth readonly property real toastHeight: Theme.px(toastContent.height + Theme.spacingL * 2, dpr) anchors { @@ -208,7 +208,7 @@ PanelWindow { buttonSize: Theme.iconSize + 8 anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - visible: ToastService.hasDetails || ToastService.currentLevel === ToastService.levelError + visible: ToastService.hasDetails || ToastService.currentLevel === ToastService.levelError || ToastService.isStickyCategory(ToastService.currentCategory) onClicked: { ToastService.hideToast(); @@ -400,7 +400,7 @@ PanelWindow { MouseArea { anchors.fill: parent - visible: !ToastService.hasDetails + visible: !ToastService.hasDetails && !ToastService.isStickyCategory(ToastService.currentCategory) onClicked: ToastService.hideToast() } diff --git a/quickshell/Services/ToastService.qml b/quickshell/Services/ToastService.qml index 7684f90b..67bd149b 100644 --- a/quickshell/Services/ToastService.qml +++ b/quickshell/Services/ToastService.qml @@ -23,6 +23,11 @@ Singleton { property var lastErrorTime: ({}) property int errorThrottleMs: 1000 property string currentCategory: "" + readonly property var stickyCategories: ["greeter-autologin-sync"] + + function isStickyCategory(category) { + return category && stickyCategories.indexOf(category) >= 0 + } function showToast(message, level = levelInfo, details = "", command = "", category = "") { const now = Date.now() @@ -137,7 +142,9 @@ Singleton { toastVisible = true resetToastState() - if (toast.level === levelError && hasDetails) { + if (isStickyCategory(toast.category)) { + toastTimer.stop() + } else if (toast.level === levelError && hasDetails) { toastTimer.interval = 8000 toastTimer.start() } else { @@ -153,6 +160,9 @@ Singleton { } function restartTimer() { + if (isStickyCategory(currentCategory)) { + return + } if (hasDetails && currentLevel === levelError) { toastTimer.interval = 8000 toastTimer.restart()