package login import ( "fmt" "os" "os/exec" "strings" "syscall" "time" "unsafe" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "latchd/auth" "latchd/config" "latchd/session" "latchd/tui/bg" ) // ── pixelated user avatar ─────────────────────────────────────────────── var avatarArt = []string{ ` ▄▄▄▄▄ `, ` ▄███████▄ `, ` ██ ██ ██ `, ` ██ ██ `, ` ██ ███ ██ `, ` █████████ `, ` ██ ██ ██ `, ` ██ ██ `, } // ── messages ───────────────────────────────────────────────────────────── type tickMsg time.Time type authResult struct { ok bool err error faillock auth.FaillockStatus } type adminBypassResult struct { ok bool err error } type mode int const ( modeNormal mode = iota modeSwitcher modeUsername modePassword modeAdminUser modeAdminPass ) // ── result (sent back to main on successful login) ────────────────────── type Result struct { Username string Password string Env session.PostLoginEnv Config config.Config Preview bool } // ── model ─────────────────────────────────────────────────────────────── type Model struct { cfg config.Config preview bool width int height int automata *bg.Automata envs []session.PostLoginEnv selIdx int username string password string ucur int pcur int uscroll int pscroll int adminUser string adminPass string aucur int apcur int lockedFor string mode mode status string statusErr bool shakeOff int shakeLeft float64 shakeDec float64 spring float64 quitting bool result *Result } func New(cfg config.Config, preview bool) *Model { raw := session.ScanEnvs( cfg.X11.SessionsPath, cfg.Wayland.SessionsPath, cfg.X11.ScriptsPath, cfg.Wayland.ScriptsPath, cfg.Switcher.IncludeTTYShell, ) m := &Model{cfg: cfg, preview: preview, envs: raw} switch cfg.FocusBehaviour { case config.FocusEnvironment: if len(raw) > 0 { m.mode = modeSwitcher } else { m.mode = modeUsername } case config.FocusUsername: m.mode = modeUsername case config.FocusPassword: m.mode = modePassword default: if len(raw) > 0 { m.mode = modeSwitcher } else { m.mode = modeUsername } } return m } func (m *Model) Init() tea.Cmd { return tea.Batch(tick(), tea.EnterAltScreen) } // ── update ────────────────────────────────────────────────────────────── func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height if m.automata != nil { m.automata.Resize(msg.Width, msg.Height) } return m, nil case tea.KeyMsg: return m.handleKey(msg) case tickMsg: if m.automata != nil { m.automata.Tick() } m.tickShake() m.tickSpring() return m, tick() case authResult: return m.handleAuth(msg) case adminBypassResult: return m.handleBypass(msg) } return m, nil } func (m *Model) tickShake() { if m.shakeLeft > 0.01 || m.shakeLeft < -0.01 { m.shakeDec += 0.05 m.shakeLeft *= 0.82 m.shakeOff = int(m.shakeLeft * 6.0 * (1.0 - m.shakeDec)) if m.shakeDec >= 1.0 { m.shakeOff, m.shakeLeft, m.shakeDec = 0, 0, 0 } } } func (m *Model) tickSpring() { if m.spring > 0.5 || m.spring < -0.5 { m.spring *= 0.75 } else { m.spring = 0 } } func (m *Model) handleAuth(msg authResult) (tea.Model, tea.Cmd) { if msg.ok { m.result = &Result{ Username: m.username, Password: m.password, Env: m.selectedEnv(), Config: m.cfg, Preview: m.preview, } return m, tea.Quit } m.status = "incorrect username or password" m.statusErr = true m.shakeLeft = 1.0 m.shakeDec = 0 m.password = "" m.pcur = 0 m.pscroll = 0 if msg.faillock.Enabled && msg.faillock.LockedOut { m.lockedFor = m.username m.status = fmt.Sprintf("account locked — %d/%d attempts [Ctrl+U to unlock]", msg.faillock.Failures, msg.faillock.MaxTries) } return m, nil } func (m *Model) handleBypass(msg adminBypassResult) (tea.Model, tea.Cmd) { if msg.ok { m.lockedFor = "" m.mode = modeNormal m.adminUser = "" m.adminPass = "" m.status = "account unlocked — try logging in again" m.statusErr = false } else { m.status = "override failed: " + msg.err.Error() m.statusErr = true } return m, nil } // ── keys ──────────────────────────────────────────────────────────────── func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { k := msg.String() if m.mode == modeAdminUser || m.mode == modeAdminPass { return m.adminKey(msg) } switch k { case "esc": if m.preview { m.quitting = true return m, tea.Quit } m.mode, m.status = modeNormal, "" return m, nil case "ctrl+c": if m.preview { m.quitting = true return m, tea.Quit } return m, nil case "ctrl+u": if m.lockedFor != "" { m.mode = modeAdminUser m.status = "admin unlock · enter username" m.statusErr = false return m, nil } } switch k { case "tab", "down", "ctrl+n": m.mode = m.nextMode() case "shift+tab", "up", "ctrl+p": m.mode = m.prevMode() } if k == "enter" && m.mode == modePassword { return m, m.loginCmd() } if len(k) >= 2 && k[0] == 'f' { m.handleFn(k) return m, nil } switch m.mode { case modeSwitcher: switch k { case "left", "h": if m.selIdx > 0 { m.selIdx-- m.spring = -4 } case "right", "l": if m.selIdx < len(m.envs)-1 { m.selIdx++ m.spring = 4 } } case modeUsername: handleField(k, &m.username, &m.ucur, &m.uscroll) case modePassword: handleField(k, &m.password, &m.pcur, &m.pscroll) } return m, nil } func (m *Model) adminKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { k := msg.String() switch k { case "esc": m.mode, m.adminUser, m.adminPass, m.status = modeNormal, "", "", "" return m, nil case "enter": if m.mode == modeAdminUser && m.adminUser != "" { m.mode = modeAdminPass m.status = "admin unlock · enter password" return m, nil } if m.mode == modeAdminPass && m.adminPass != "" { return m, m.adminBypassCmd() } case "tab": if m.mode == modeAdminUser { m.mode = modeAdminPass } else { m.mode = modeAdminUser } default: if m.mode == modeAdminUser { handleField(k, &m.adminUser, &m.aucur, &m.aucur) } else { handleField(k, &m.adminPass, &m.apcur, &m.apcur) } } return m, nil } func (m *Model) nextMode() mode { switch m.mode { case modeNormal: if len(m.envs) > 0 { return modeSwitcher } return modeUsername case modeSwitcher: return modeUsername case modeUsername: return modePassword } return m.mode } func (m *Model) prevMode() mode { switch m.mode { case modeSwitcher: return modeNormal case modeUsername: if len(m.envs) > 0 { return modeSwitcher } return modeNormal case modePassword: return modeUsername } return modeNormal } func (m *Model) handleFn(k string) { for _, pc := range m.cfg.PowerControls.BaseEntries { if strings.EqualFold(k, pc.Key) { exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start() } } for _, pc := range m.cfg.PowerControls.Entries { if strings.EqualFold(k, pc.Key) { exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start() } } } func (m *Model) selectedEnv() session.PostLoginEnv { if len(m.envs) == 0 || m.selIdx >= len(m.envs) { return session.PostLoginEnv{} } return m.envs[m.selIdx] } // ── commands ──────────────────────────────────────────────────────────── func (m *Model) loginCmd() tea.Cmd { if m.preview { return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} } } user, pass, svc := m.username, m.password, m.cfg.PAMService return func() tea.Msg { _, err := auth.Validate(user, pass, svc) if err == nil { return authResult{ok: true} } fs, _ := auth.GetFaillockStatus(user) return authResult{ok: false, err: err, faillock: fs} } } func (m *Model) adminBypassCmd() tea.Cmd { au, ap, tgt := m.adminUser, m.adminPass, m.lockedFor return func() tea.Msg { if _, err := auth.Validate(au, ap, "su"); err != nil { return adminBypassResult{ok: false, err: fmt.Errorf("admin auth failed")} } if err := auth.ResetFaillock(tgt); err != nil { return adminBypassResult{ok: false, err: fmt.Errorf("faillock: %w", err)} } return adminBypassResult{ok: true} } } // ── queries ──────────────────────────────────────────────────────────── func (m *Model) Result() *Result { return m.result } func (m *Model) Quitting() bool { return m.quitting } func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a } // ── view ─────────────────────────────────────────────────────────────── var ( accent = lipgloss.Color("208") // orange subtle = lipgloss.Color("8") // grey bright = lipgloss.Color("15") // white errCol = lipgloss.Color("1") // red infoCol = lipgloss.Color("3") // yellow greenCol = lipgloss.Color("2") // green avatarStyle = lipgloss.NewStyle().Foreground(accent) fieldBox = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(subtle). Padding(0, 1). Width(34) fieldBoxFocused = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(accent). Padding(0, 1). Width(34) fieldLabel = lipgloss.NewStyle().Foreground(subtle).Padding(0, 0) fieldText = lipgloss.NewStyle().Foreground(bright) fieldFocus = lipgloss.NewStyle().Foreground(accent) swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true) swNeighbour = lipgloss.NewStyle().Foreground(subtle) swMover = lipgloss.NewStyle().Foreground(accent).Bold(true) swDot = lipgloss.NewStyle().Foreground(subtle) swDotActive = lipgloss.NewStyle().Foreground(accent) powerHint = lipgloss.NewStyle().Foreground(subtle).Faint(true) capsOnStyle = lipgloss.NewStyle().Foreground(greenCol).Bold(true) capsOffStyle = lipgloss.NewStyle().Foreground(subtle).Faint(true) statusErrStyle = lipgloss.NewStyle().Foreground(errCol) statusInfoStyle = lipgloss.NewStyle().Foreground(infoCol) adminLabel = lipgloss.NewStyle().Foreground(infoCol).Bold(true) ) func (m *Model) View() string { if m.quitting || m.result != nil { return "" } content := m.renderLayout() if m.automata != nil && m.width > 0 && m.height > 0 { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content, lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceForeground(lipgloss.Color("0")), ) } return content } func (m *Model) renderLayout() string { // top bar: power hints top := m.renderPowerHints() top = lipgloss.NewStyle().Width(80).Align(lipgloss.Left).Render(top) // avatar + form side by side avatar := m.renderAvatar() form := m.renderForm() mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form) // DE switcher below sw := m.renderSwitcherDots() // caps lock caps := m.renderCapsLock() // admin unlock var admin string if m.mode == modeAdminUser || m.mode == modeAdminPass { admin = m.renderAdminForm() } // status st := m.renderStatus() var parts []string parts = append(parts, top) parts = append(parts, "") parts = append(parts, mid) parts = append(parts, "") parts = append(parts, sw) if caps != "" { parts = append(parts, caps) } if admin != "" { parts = append(parts, "") parts = append(parts, admin) } if st != "" { parts = append(parts, st) } joined := lipgloss.JoinVertical(lipgloss.Center, parts...) if m.shakeOff != 0 { pad := strings.Repeat(" ", abs(m.shakeOff)) if m.shakeOff > 0 { joined = pad + joined } } return joined } func (m *Model) renderPowerHints() string { var hints []string for _, pc := range m.cfg.PowerControls.BaseEntries { hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint)) } for _, pc := range m.cfg.PowerControls.Entries { hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint)) } return strings.Join(hints, " ") } func (m *Model) renderAvatar() string { var lines []string for _, row := range avatarArt { lines = append(lines, avatarStyle.Render(row)) } return lipgloss.JoinVertical(lipgloss.Left, lines...) } func (m *Model) renderForm() string { userLabel := fieldLabel.Render("username") if m.mode == modeUsername { userLabel = fieldFocus.Render("▸ username") } userVal := m.username if userVal == "" { userVal = " " } ubox := fieldBoxFocused utxt := fieldFocus if m.mode != modeUsername { ubox = fieldBox utxt = fieldText } userField := ubox.Render(userLabel + "\n" + utxt.Render(userVal)) passLabel := fieldLabel.Render("password") if m.mode == modePassword { passLabel = fieldFocus.Render("▸ password") } passVal := strings.Repeat("•", len(m.password)) if passVal == "" { passVal = " " } pbox := fieldBoxFocused ptxt := fieldFocus if m.mode != modePassword { pbox = fieldBox ptxt = fieldText } passField := pbox.Render(passLabel + "\n" + ptxt.Render(passVal)) return lipgloss.JoinVertical(lipgloss.Left, userField, "", passField) } func (m *Model) renderSwitcherDots() string { if len(m.envs) == 0 { return swNeighbour.Align(lipgloss.Center).Render("no desktop environments found") } cfg := m.cfg.Switcher w := int(cfg.MaxDisplayLength) var parts []string if cfg.ShowMovers && m.selIdx > 0 { parts = append(parts, swMover.Render(cfg.LeftMover+" ")) } if cfg.ShowNeighbours && m.selIdx > 0 { parts = append(parts, swNeighbour.Render(padTrim(m.envs[m.selIdx-1].Title, w)+" ")) } cur := swSelected.Render(padTrim(m.envs[m.selIdx].Title, w)) parts = append(parts, cur) if cfg.ShowNeighbours && m.selIdx < len(m.envs)-1 { parts = append(parts, swNeighbour.Render(" "+padTrim(m.envs[m.selIdx+1].Title, w))) } if cfg.ShowMovers && m.selIdx < len(m.envs)-1 { parts = append(parts, swMover.Render(" "+cfg.RightMover)) } swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...) // Dot indicators var dots []string for i := 0; i < len(m.envs); i++ { if i == m.selIdx { dots = append(dots, swDotActive.Render("●")) } else { dots = append(dots, swDot.Render("·")) } } dotLine := strings.Join(dots, " ") return lipgloss.JoinVertical(lipgloss.Center, swLine, dotLine) } func (m *Model) renderCapsLock() string { if capsLockOn() { return capsOnStyle.Align(lipgloss.Center).Render("⬤ Caps Lock") } return "" } func (m *Model) renderAdminForm() string { label := adminLabel.Render("── admin override ──") auLabel := fieldLabel.Render("admin username") if m.mode == modeAdminUser { auLabel = fieldFocus.Render("▸ admin username") } auVal := m.adminUser if auVal == "" { auVal = " " } auBox := fieldBox if m.mode == modeAdminUser { auBox = fieldBoxFocused } auField := auBox.Render(auLabel + "\n" + fieldText.Render(auVal)) apLabel := fieldLabel.Render("admin password") if m.mode == modeAdminPass { apLabel = fieldFocus.Render("▸ admin password") } apVal := strings.Repeat("•", len(m.adminPass)) if apVal == "" { apVal = " " } apBox := fieldBox if m.mode == modeAdminPass { apBox = fieldBoxFocused } apField := apBox.Render(apLabel + "\n" + fieldText.Render(apVal)) return lipgloss.JoinVertical(lipgloss.Center, label, "", auField, "", apField, ) } func (m *Model) renderStatus() string { if m.status == "" { return "" } st := statusInfoStyle if m.statusErr { st = statusErrStyle } return st.Align(lipgloss.Center).Width(70).Render(m.status) } // ── caps lock detection ──────────────────────────────────────────────── const kdgkbled = 0x4B64 func capsLockOn() bool { fd := int(os.Stdin.Fd()) var leds int32 _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), kdgkbled, uintptr(unsafe.Pointer(&leds))) if errno != 0 { return false } return leds&0x04 != 0 } // ── field editing ────────────────────────────────────────────────────── func handleField(k string, buf *string, cur, scroll *int) { switch { case k == "backspace" || k == "ctrl+h": if *cur == 0 && *scroll == 0 { return } moveLeft(cur, scroll, *buf) r := []rune(*buf) idx := *scroll + *cur if idx < len(r) { *buf = string(append(r[:idx], r[idx+1:]...)) } case k == "delete" || k == "ctrl+d": r := []rune(*buf) idx := *scroll + *cur if idx < len(r) { *buf = string(append(r[:idx], r[idx+1:]...)) } case k == "ctrl+a": *cur, *scroll = 0, 0 case k == "ctrl+e": *cur, *scroll = len([]rune(*buf)), 0 case k == "ctrl+l", k == "ctrl+u": *buf, *cur, *scroll = "", 0, 0 case k == "left" || k == "ctrl+b": moveLeft(cur, scroll, *buf) case k == "right" || k == "ctrl+f": moveRight(cur, scroll, *buf) default: if len(k) == 1 { r := []rune(*buf) idx := *scroll + *cur if idx > len(r) { idx = len(r) } ch := []rune(k)[0] r = append(r[:idx], append([]rune{ch}, r[idx:]...)...) *buf = string(r) if *cur >= 30 { *scroll++ } else { *cur++ } } } } func moveLeft(cur, scroll *int, buf string) { if *cur > 0 { *cur-- return } if *scroll > 0 { *scroll-- } } func moveRight(cur, scroll *int, buf string) { if *scroll+*cur >= len([]rune(buf)) { return } if *cur >= 30 { *scroll++ } else { *cur++ } } func padTrim(s string, w int) string { if len(s) > w { return s[:w] } return s } func abs(n int) int { if n < 0 { return -n } return n } // ── tick ──────────────────────────────────────────────────────────────── func tick() tea.Cmd { return tea.Tick(time.Second/30, func(t time.Time) tea.Msg { return tickMsg(t) }) }