Compare commits

...

3 Commits

Author SHA1 Message Date
1723ab53b8 docs: update CHANGELOG, README keybindings, gitignore ly/
Some checks failed
Integration Check CI / check (push) Has been cancelled
Continuous integration / Check (push) Has been cancelled
Continuous integration / Rustfmt (push) Has been cancelled
Continuous integration / Clippy (push) Has been cancelled
2026-07-19 01:54:45 +05:30
d7c9951d6b ui: wire automata background, add clock + session specifier + form card
- Automata.Frame() rendered as base layer, form card overlaid via ANSI
  cursor positioning with dark background for readability
- Clock widget showing datetime (Mon Jan 02 15:04:05) in top bar
- Session type label (x11/wayland) shown above switcher selection
2026-07-19 01:54:40 +05:30
a911adf284 session: parse DesktopNames for XDG_CURRENT_DESKTOP, wire utmpx, fix shell
- .desktop files now parse DesktopNames: first ;-entry → XDG_SESSION_DESKTOP,
  full list with : separators → XDG_CURRENT_DESKTOP
- AddUtmpx/Close wired into spawnSession (logins appear in who/w/last)
- Credentials.Shell reads from /etc/passwd instead of hardcoded /bin/sh
- Added SetDesktopVars to env.go
2026-07-19 01:54:22 +05:30
9 changed files with 145 additions and 30 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ session.md
build/* build/*
examples/* examples/*
things_todo.md things_todo.md
ly/*

View File

@@ -24,6 +24,14 @@ first public release
### Recent Fixes ### Recent Fixes
- **DesktopNames parsing** — `.desktop` files now parse the `DesktopNames` field. the first semicolon-delimited entry becomes `XDG_SESSION_DESKTOP`, and the full list with `:` separators becomes `XDG_CURRENT_DESKTOP`. fixes GNOME/KDE sessions not getting proper environment variables
- **utmpx accounting wired** — `AddUtmpx` and `Close` are now called from `spawnSession` so user logins appear in `who`/`w`/`last`
- **fixed hardcoded shell** — `Credentials.Shell` now reads from `/etc/passwd` instead of always returning `/bin/sh`. removes duplicate `userShell` logic in exec.go
- **automata background wired** — `Automata.Frame()` is now rendered in `View()` as the base layer with the login form card overlaid using ANSI cursor positioning. the form has a dark card background for readability
- **clock widget** — added a datetime display (`Mon Jan 02 15:04:05`) to the top bar, updated every tick
- **session specifier label** — the environment switcher now shows the session type (`x11`, `wayland`) above the session name, like ly's specifier
- **form card background** — login content is rendered inside a padded card with a dark background so it reads cleanly over the animated automata
- **utmpx time field portability** — replaced hardcoded `C.__uint32_t` / `C.__int32_t` casts with a C helper function (`auth/utmpx_time.h`) that lets the compiler handle type conversion. fixes build failure on systems where `ut_tv` uses `struct timeval` instead of the 32/64 compat struct - **utmpx time field portability** — replaced hardcoded `C.__uint32_t` / `C.__int32_t` casts with a C helper function (`auth/utmpx_time.h`) that lets the compiler handle type conversion. fixes build failure on systems where `ut_tv` uses `struct timeval` instead of the 32/64 compat struct
- **cgo pointer safety for pam** — replaced `unsafe.Pointer(&h)` (Go stack pointer passed to C) with `storeHandle`/`loadHandle` helpers that keep the `cgo.Handle` in C-heap memory. fixes `cgo argument has Go pointer to unpinned Go pointer` panic on Go 1.22+ - **cgo pointer safety for pam** — replaced `unsafe.Pointer(&h)` (Go stack pointer passed to C) with `storeHandle`/`loadHandle` helpers that keep the `cgo.Handle` in C-heap memory. fixes `cgo argument has Go pointer to unpinned Go pointer` panic on Go 1.22+
- **pam conversation callback** — fixed `unsafe.Slice` using `&r` (pointer-to-pointer) as the backing array instead of `r` (the calloc'd response array), which wrote password bytes into random stack memory. also now only responds to `PAM_PROMPT_ECHO_OFF`/`PAM_PROMPT_ECHO_ON` messages — info and error messages get nil. fixes `pam_unix: auth could not identify password` - **pam conversation callback** — fixed `unsafe.Slice` using `&r` (pointer-to-pointer) as the backing array instead of `r` (the calloc'd response array), which wrote password bytes into random stack memory. also now only responds to `PAM_PROMPT_ECHO_OFF`/`PAM_PROMPT_ECHO_ON` messages — info and error messages get nil. fixes `pam_unix: auth could not identify password`
@@ -46,4 +54,4 @@ first public release
- session leader is the latchd process, not the desktop — pam session tracking via systemd-logind may be imperfect - session leader is the latchd process, not the desktop — pam session tracking via systemd-logind may be imperfect
- not all config options affect the rendering yet - not all config options affect the rendering yet
- occasional terminal state corruption on abrupt exit - occasional terminal state corruption on abrupt exit
- automata background rendered as tick-only visual (not wired into the view yet) - automata background visible at screen edges; form card covers the center

View File

@@ -65,9 +65,9 @@ pacman -S latchd
systemctl enable latchd systemctl enable latchd
``` ```
## command line ## Command Line
``` ```bash
latchd [options] latchd [options]
-c, --config <file> config path (default: /etc/latchd/config.toml) -c, --config <file> config path (default: /etc/latchd/config.toml)
@@ -82,9 +82,9 @@ latchd [options]
--initial-path <path> override initial PATH value --initial-path <path> override initial PATH value
``` ```
## adding sessions ## Adding Sessions
drop executable scripts into the right folder and they show up in the switcher. Drop executable scripts into the right folder and they show up in the switcher.
**x11** — put your xinitrc scripts in `/etc/latchd/wms/`: **x11** — put your xinitrc scripts in `/etc/latchd/wms/`:
@@ -113,7 +113,7 @@ tty sessions are added automatically if no other environments are found, or you
## configuring ## configuring
the config lives at `/etc/latchd/config.toml`. all options are documented inline in the file. you can also use a `variables.toml` file with `$VAR` syntax: The config lives at `/etc/latchd/config.toml`. all options are documented inline in the file. you can also use a `variables.toml` file with `$VAR` syntax:
```toml ```toml
# /etc/latchd/variables.toml # /etc/latchd/variables.toml
@@ -127,7 +127,7 @@ title_color_focused = "$accent"
border_color_focused = "$accent" border_color_focused = "$accent"
``` ```
## debugging ## Debugging
three log files: three log files:
@@ -137,7 +137,7 @@ three log files:
run `latchd --show-config` to verify your configuration. run `latchd --preview` to test the login screen without leaving your current session. run `latchd --show-config` to verify your configuration. run `latchd --preview` to test the login screen without leaving your current session.
## keybindings ## Keybindings
standard shell-style bindings in text fields: standard shell-style bindings in text fields:
@@ -151,6 +151,7 @@ standard shell-style bindings in text fields:
| ctrl+b/f | left/right | | ctrl+b/f | left/right |
| ctrl+p/n | up/down (previous/next field) | | ctrl+p/n | up/down (previous/next field) |
| tab, shift+tab | next/prev field | | tab, shift+tab | next/prev field |
| down / up | next/prev field |
| left/right | previous/next session (switcher mode) | | left/right | previous/next session (switcher mode) |
| , / . | previous/next session (any mode) | | , / . | previous/next session (any mode) |
| f1, f2 | power controls | | f1, f2 | power controls |
@@ -158,7 +159,7 @@ standard shell-style bindings in text fields:
## source layout ## source layout
``` ```bash
src/ src/
├── main.go entry point, cli, vt switching, session forking ├── main.go entry point, cli, vt switching, session forking
├── auth/ ├── auth/

View File

@@ -37,9 +37,11 @@ static struct pam_conv makePAMConv(void *data) {
import "C" import "C"
import ( import (
"fmt" "fmt"
"os"
ouser "os/user" ouser "os/user"
"runtime" "runtime"
"runtime/cgo" "runtime/cgo"
"strings"
"unsafe" "unsafe"
) )
@@ -169,7 +171,7 @@ func Validate(user, pass, service string) (*Credentials, error) {
PrimaryGID: gid, PrimaryGID: gid,
AllGIDs: gids, AllGIDs: gids,
HomeDir: u.HomeDir, HomeDir: u.HomeDir,
Shell: "/bin/sh", Shell: shellFromPasswd(uid),
pamHandle: pamh, pamHandle: pamh,
pamData: cData, pamData: cData,
pamCB: h, pamCB: h,
@@ -211,3 +213,21 @@ func atou32(s string) uint32 {
} }
return n return n
} }
func shellFromPasswd(uid uint32) string {
data, err := os.ReadFile("/etc/passwd")
if err != nil {
return "/bin/sh"
}
uidStr := fmt.Sprint(uid)
for _, line := range strings.Split(string(data), "\n") {
parts := strings.Split(line, ":")
if len(parts) >= 7 && parts[2] == uidStr && parts[6] != "" {
sh := parts[6]
if sh != "/sbin/nologin" && sh != "/usr/sbin/nologin" && sh != "/bin/false" {
return sh
}
}
}
return "/bin/sh"
}

View File

@@ -438,6 +438,9 @@ var (
infoCol = lipgloss.Color("3") // yellow infoCol = lipgloss.Color("3") // yellow
greenCol = lipgloss.Color("2") // green greenCol = lipgloss.Color("2") // green
cardBg = lipgloss.Color("0") // black card background
cardPad = 2
avatarStyle = lipgloss.NewStyle().Foreground(accent) avatarStyle = lipgloss.NewStyle().Foreground(accent)
fieldBox = lipgloss.NewStyle(). fieldBox = lipgloss.NewStyle().
@@ -481,6 +484,14 @@ func (m *Model) View() string {
content := m.renderLayout() content := m.renderLayout()
if m.width > 0 && m.height > 0 { if m.width > 0 && m.height > 0 {
if m.automata != nil {
bg := m.automata.Frame()
var sb strings.Builder
sb.WriteString(bg)
sb.WriteString("\x1b[H") // cursor home before overlaying
sb.WriteString(overlayCenter(m.width, m.height, content))
return sb.String()
}
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
content, content,
) )
@@ -488,17 +499,49 @@ func (m *Model) View() string {
return content return content
} }
func overlayCenter(w, h int, content string) string {
lines := strings.Split(content, "\n")
cw := 0
for _, l := range lines {
if lw := lipgloss.Width(l); lw > cw {
cw = lw
}
}
cx := (w - cw) / 2
cy := (h - len(lines)) / 2
if cx < 0 {
cx = 0
}
if cy < 0 {
cy = 0
}
var sb strings.Builder
for i, line := range lines {
sb.WriteString(fmt.Sprintf("\x1b[%d;%dH", cy+i+1, cx+1))
sb.WriteString(line)
}
return sb.String()
}
func (m *Model) renderLayout() string { func (m *Model) renderLayout() string {
// top bar: power hints clock := m.renderClock()
top := m.renderPowerHints()
// top bar: power hints on left, clock on right
var topParts []string
topParts = append(topParts, m.renderPowerHints())
if clock != "" {
topParts = append(topParts, clock)
}
top := strings.Join(topParts, " ")
// avatar + form side by side // avatar + form side by side
avatar := m.renderAvatar() avatar := m.renderAvatar()
form := m.renderForm() form := m.renderForm()
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form) mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
// DE switcher below // DE switcher with specifier label
sw := m.renderSwitcherDots() sw := m.renderSwitcher()
// caps lock // caps lock
caps := m.renderCapsLock() caps := m.renderCapsLock()
@@ -536,7 +579,10 @@ func (m *Model) renderLayout() string {
joined = pad + joined joined = pad + joined
} }
} }
return joined return lipgloss.NewStyle().
Background(cardBg).
Padding(cardPad, cardPad+2).
Render(joined)
} }
func (m *Model) renderPowerHints() string { func (m *Model) renderPowerHints() string {
@@ -594,7 +640,12 @@ func (m *Model) renderForm() string {
return lipgloss.JoinVertical(lipgloss.Left, userField, "", passField) return lipgloss.JoinVertical(lipgloss.Left, userField, "", passField)
} }
func (m *Model) renderSwitcherDots() string { func (m *Model) renderClock() string {
now := time.Now()
return powerHint.Render(now.Format("Mon Jan 02 15:04:05"))
}
func (m *Model) renderSwitcher() string {
if len(m.envs) == 0 { if len(m.envs) == 0 {
return swNeighbour.Align(lipgloss.Center).Render("no desktop environments found") return swNeighbour.Align(lipgloss.Center).Render("no desktop environments found")
} }
@@ -604,6 +655,9 @@ func (m *Model) renderSwitcherDots() string {
var parts []string var parts []string
// session specifier (like "x11", "wayland")
spec := swDot.Render(m.envs[m.selIdx].Kind + " ")
if cfg.ShowMovers && m.selIdx > 0 { if cfg.ShowMovers && m.selIdx > 0 {
parts = append(parts, swMover.Render(cfg.LeftMover+" ")) parts = append(parts, swMover.Render(cfg.LeftMover+" "))
} }
@@ -636,7 +690,7 @@ func (m *Model) renderSwitcherDots() string {
} }
dotLine := strings.Join(dots, " ") dotLine := strings.Join(dots, " ")
return lipgloss.JoinVertical(lipgloss.Center, swLine, dotLine) return lipgloss.JoinVertical(lipgloss.Center, spec, swLine, dotLine)
} }
func (m *Model) renderCapsLock() string { func (m *Model) renderCapsLock() string {

View File

@@ -165,12 +165,16 @@ func spawnSession(res *login.Result) error {
session.SetSeatVars(res.Config.TTY) session.SetSeatVars(res.Config.TTY)
session.SetSessionVars(creds.UID) session.SetSessionVars(creds.UID)
session.SetXDGCommonPaths(creds.HomeDir) session.SetXDGCommonPaths(creds.HomeDir)
session.SetDesktopVars(res.Env.DesktopNames, res.Env.SessionDesktop)
sess, err := session.Spawn(&res.Env, creds.UID, creds.PrimaryGID, creds.AllGIDs, &res.Config) sess, err := session.Spawn(&res.Env, creds.UID, creds.PrimaryGID, creds.AllGIDs, &res.Config)
if err != nil { if err != nil {
return fmt.Errorf("spawn: %w", err) return fmt.Errorf("spawn: %w", err)
} }
utmpx := auth.AddUtmpx(creds.Username, res.Config.TTY, uint32(sess.PID()))
defer utmpx.Close()
err = sess.Wait() err = sess.Wait()
if err != nil { if err != nil {
log.Printf("session exited: %v", err) log.Printf("session exited: %v", err)

View File

@@ -57,6 +57,15 @@ func SetXDGCommonPaths(home string) {
setOrOwn("XDG_CONFIG_DIRS", "/etc/xdg") setOrOwn("XDG_CONFIG_DIRS", "/etc/xdg")
} }
func SetDesktopVars(currentDesktop, sessionDesktop string) {
if currentDesktop != "" {
setOrOwn("XDG_CURRENT_DESKTOP", currentDesktop)
}
if sessionDesktop != "" {
setOrOwn("XDG_SESSION_DESKTOP", sessionDesktop)
}
}
func setOrOwn(k, v string) { func setOrOwn(k, v string) {
if _, ok := os.LookupEnv(k); !ok { if _, ok := os.LookupEnv(k); !ok {
os.Setenv(k, v) os.Setenv(k, v)

View File

@@ -20,6 +20,8 @@ type PostLoginEnv struct {
Title string Title string
XinitrcPath string XinitrcPath string
ScriptPath string ScriptPath string
DesktopNames string // for XDG_CURRENT_DESKTOP (colon-separated)
SessionDesktop string // for XDG_SESSION_DESKTOP
} }
type SpawnedEnv struct { type SpawnedEnv struct {

View File

@@ -15,6 +15,7 @@ import (
type DesktopEntry struct { type DesktopEntry struct {
Name string Name string
Exec string Exec string
DesktopNames string
Hidden bool Hidden bool
NoDisplay bool NoDisplay bool
} }
@@ -51,6 +52,8 @@ func parseDesktopEntry(path string) (*DesktopEntry, error) {
e.Name = v e.Name = v
case "Exec": case "Exec":
e.Exec = v e.Exec = v
case "DesktopNames":
e.DesktopNames = v
case "Hidden": case "Hidden":
e.Hidden = strings.EqualFold(v, "true") e.Hidden = strings.EqualFold(v, "true")
case "NoDisplay": case "NoDisplay":
@@ -97,15 +100,28 @@ func scanDesktopDir(dir, kind string, envs *[]PostLoginEnv) {
if err != nil || de.Hidden || de.NoDisplay { if err != nil || de.Hidden || de.NoDisplay {
continue continue
} }
desktopNames, sessionDesktop := splitDesktopNames(de.DesktopNames, e.Name())
*envs = append(*envs, PostLoginEnv{ *envs = append(*envs, PostLoginEnv{
Kind: kind, Kind: kind,
Title: de.Name, Title: de.Name,
XinitrcPath: de.Exec, XinitrcPath: de.Exec,
ScriptPath: de.Exec, ScriptPath: de.Exec,
DesktopNames: desktopNames,
SessionDesktop: sessionDesktop,
}) })
} }
} }
func splitDesktopNames(raw, fallback string) (currentDesktop, sessionDesktop string) {
if raw == "" {
return fallback, fallback
}
parts := strings.SplitN(raw, ";", 2)
sessionDesktop = strings.TrimSpace(parts[0])
currentDesktop = strings.ReplaceAll(raw, ";", ":")
return
}
func scanScriptDir(dir, kind string, envs *[]PostLoginEnv) { func scanScriptDir(dir, kind string, envs *[]PostLoginEnv) {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {