Files
latchd/src/login/login.go
2026-07-18 18:16:06 +05:30

786 lines
18 KiB
Go

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)
})
}