Compare commits

..

10 Commits

Author SHA1 Message Date
5eae08c7ba (Feat): Minor Changes
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-18 23:07:56 +05:30
6d7a756cd9 (Feat): Added a global key to navigate between different sessions 2026-07-18 23:07:42 +05:30
f6d3acbfbf (Minor): Updated changelog.md 2026-07-18 22:57:50 +05:30
0050c5cf58 (Fix): Minor Fix 2026-07-18 22:57:35 +05:30
f8156f85a5 (Fix): pam_open_session failure (e.g. pam_systemd rejecting the DM process as session leader) is now logged as a warning instead of blocking the entire session spawn. 2026-07-18 22:57:05 +05:30
1eb900c27c (Fix): spawnSession now calls SetBasicVariables, SetDisplay, SetSessionParams, SetSeatVars, SetSessionVars, and SetXDGCommonPaths before spawning the desktop. fixes setupX failing immediately with missing DISPLAY/XDG_VTNR/HOME env vars because none were set 2026-07-18 22:40:32 +05:30
c646c7a18b (Fix): removed the non-functional automata background overlay from View() (the Frame() was never called, and the black-space placeholder caused EEEE garbage characters on some terminals) 2026-07-18 22:00:45 +05:30
eedde29e03 (Fix): 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 2026-07-18 21:42:05 +05:30
863ed82298 (Fix): replaced the broken ForkExec (which relaunched the same binary with made-up flags) with a direct session.Spawn call that drops privileges and runs the desktop in-process. 2026-07-18 21:41:27 +05:30
37a9ea0278 (Feat): renamed from lemurs to latchd 2026-07-18 21:40:10 +05:30
7 changed files with 75 additions and 33 deletions

View File

@@ -27,15 +27,23 @@ first public release
- **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`
- **cgo handle lifecycle** — moved `cgo.Handle` and its C-heap backing from `Validate` (where they were `defer`-freed on return) into the `Credentials` struct so they stay alive through `OpenSession` and until `CloseSession`. fixes `misuse of an invalid Handle` panic
- **session spawning** — replaced the broken `ForkExec` (which relaunched the same binary with made-up flags) with a direct `session.Spawn` call that drops privileges and runs the desktop in-process. fixes `flag provided but not defined: -user`
- **renamed extra/ files** — `lemurs.pam``latchd.pam`, `lemurs.service``latchd.service`
- **readme credits** — added a credits section acknowledging lemurs for the `extra/` directory structure and session scanning approach
- **makefile `enable` and `update` targets** — `make enable` stops any running DM, disables the aliased display-manager, and enables latchd. `make update` runs uninstall → git pull → reinstall in one command - **makefile `enable` and `update` targets** — `make enable` stops any running DM, disables the aliased display-manager, and enables latchd. `make update` runs uninstall → git pull → reinstall in one command
- **missing `extra/` directory** — added `extra/config.toml`, `extra/xsetup.sh`, `extra/lemurs.pam`, and `extra/lemurs.service` so `make install` can find them - **missing `extra/` directory** — added `extra/config.toml`, `extra/xsetup.sh`, `extra/lemurs.pam`, and `extra/lemurs.service` so `make install` can find them
- **makefile `deps` target** — added `make deps` to automatically install `libpam0g-dev` and `build-essential`, and verify go is present - **makefile `deps` target** — added `make deps` to automatically install `libpam0g-dev` and `build-essential`, and verify go is present
- **readme overhaul** — rewrote for clarity: added "what problem does this solve", "how it works", known issues, simpler install instructions, and proper dependency listing - **readme overhaul** — rewrote for clarity: added "what problem does this solve", "how it works", known issues, simpler install instructions, and proper dependency listing
- **view cleanup** — removed the non-functional automata background overlay from `View()` (the `Frame()` was never called, and the black-space placeholder caused `EEEE` garbage characters on some terminals). also removed a hardcoded 80-char width on the top bar so it adapts to the terminal
- **session environment setup** — `spawnSession` now calls `SetBasicVariables`, `SetDisplay`, `SetSessionParams`, `SetSeatVars`, `SetSessionVars`, and `SetXDGCommonPaths` before spawning the desktop. fixes `setupX` failing immediately with `missing DISPLAY/XDG_VTNR/HOME env vars` because none were set
- **pam session error made non-fatal** — `pam_open_session` failure (e.g. `pam_systemd` rejecting the DM process as session leader) is now logged as a warning instead of blocking the entire session spawn. also removed `session include login` from `latchd.pam` since the DM process can't register user sessions with systemd-logind
- **global session switching with `,` and `.`** — sessions can now be switched from any mode (not just when the switcher is focused). arrows still work when switcher is focused
### What's Missing ### What's Missing
- no tests - no tests
- session spawning uses `ForkExec` which is less clean than the original fork-then-exec model - 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 can flicker on rapid terminal resizes - automata background rendered as tick-only visual (not wired into the view yet)

View File

@@ -151,7 +151,8 @@ 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 |
| left/right | switch session (in switcher mode) | | left/right | previous/next session (switcher mode) |
| , / . | previous/next session (any mode) |
| f1, f2 | power controls | | f1, f2 | power controls |
| ctrl+u | admin unlock (when account is locked) | | ctrl+u | admin unlock (when account is locked) |
@@ -182,3 +183,7 @@ src/
## license ## license
MIT or Apache-2.0 MIT or Apache-2.0
## credits
the `extra/` directory structure and the session scanning / pam delegation approach are adapted from [lemurs](https://github.com/coastalwhite/lemurs), a rust display manager.

View File

@@ -1,5 +1,4 @@
#%PAM-1.0 #%PAM-1.0
auth include login auth include login
account include login account include login
session include login
password include login password include login

View File

@@ -67,6 +67,8 @@ type Credentials struct {
HomeDir string HomeDir string
Shell string Shell string
pamHandle *C.pam_handle_t pamHandle *C.pam_handle_t
pamData unsafe.Pointer
pamCB cgo.Handle
} }
//export latchd_pam_conv //export latchd_pam_conv
@@ -110,16 +112,16 @@ func Validate(user, pass, service string) (*Credentials, error) {
defer C.free(unsafe.Pointer(cUser)) defer C.free(unsafe.Pointer(cUser))
h := cgo.NewHandle(pass) h := cgo.NewHandle(pass)
defer h.Delete()
cData := C.storeHandle(C.uintptr_t(h)) cData := C.storeHandle(C.uintptr_t(h))
defer C.free(cData)
conv := C.makePAMConv(cData) conv := C.makePAMConv(cData)
var pamh *C.pam_handle_t var pamh *C.pam_handle_t
ret := C.pam_start(cSvc, cUser, &conv, &pamh) ret := C.pam_start(cSvc, cUser, &conv, &pamh)
if ret != C.PAM_SUCCESS { if ret != C.PAM_SUCCESS {
h.Delete()
C.free(cData)
return nil, &AuthError{ErrPAMService, fmt.Sprintf("pam_start: %s", pamErr(pamh, ret))} return nil, &AuthError{ErrPAMService, fmt.Sprintf("pam_start: %s", pamErr(pamh, ret))}
} }
@@ -127,6 +129,8 @@ func Validate(user, pass, service string) (*Credentials, error) {
if ret != C.PAM_SUCCESS { if ret != C.PAM_SUCCESS {
msg := fmt.Sprintf("auth failed: %s", pamErr(pamh, ret)) msg := fmt.Sprintf("auth failed: %s", pamErr(pamh, ret))
C.pam_end(pamh, ret) C.pam_end(pamh, ret)
h.Delete()
C.free(cData)
return nil, &AuthError{ErrAccountValidation, msg} return nil, &AuthError{ErrAccountValidation, msg}
} }
@@ -134,12 +138,16 @@ func Validate(user, pass, service string) (*Credentials, error) {
if ret != C.PAM_SUCCESS { if ret != C.PAM_SUCCESS {
msg := fmt.Sprintf("account: %s", pamErr(pamh, ret)) msg := fmt.Sprintf("account: %s", pamErr(pamh, ret))
C.pam_end(pamh, ret) C.pam_end(pamh, ret)
h.Delete()
C.free(cData)
return nil, &AuthError{ErrAccountValidation, msg} return nil, &AuthError{ErrAccountValidation, msg}
} }
u, err := ouser.Lookup(user) u, err := ouser.Lookup(user)
if err != nil { if err != nil {
C.pam_end(pamh, ret) C.pam_end(pamh, ret)
h.Delete()
C.free(cData)
return nil, &AuthError{ErrUsernameNotFound, fmt.Sprintf("lookup %s: %v", user, err)} return nil, &AuthError{ErrUsernameNotFound, fmt.Sprintf("lookup %s: %v", user, err)}
} }
@@ -163,6 +171,8 @@ func Validate(user, pass, service string) (*Credentials, error) {
HomeDir: u.HomeDir, HomeDir: u.HomeDir,
Shell: "/bin/sh", Shell: "/bin/sh",
pamHandle: pamh, pamHandle: pamh,
pamData: cData,
pamCB: h,
}, nil }, nil
} }
@@ -180,6 +190,11 @@ func (c *Credentials) CloseSession() {
C.pam_end(c.pamHandle, 0) C.pam_end(c.pamHandle, 0)
c.pamHandle = nil c.pamHandle = nil
} }
if c.pamData != nil {
C.free(c.pamData)
c.pamData = nil
}
c.pamCB.Delete()
} }
func pamErr(h *C.pam_handle_t, e C.int) string { func pamErr(h *C.pam_handle_t, e C.int) string {

View File

@@ -270,6 +270,25 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if len(m.envs) > 0 {
switch k {
case ",", "<":
m.selIdx--
if m.selIdx < 0 {
m.selIdx = len(m.envs) - 1
}
m.spring = -4
return m, nil
case ".", ">":
m.selIdx++
if m.selIdx >= len(m.envs) {
m.selIdx = 0
}
m.spring = 4
return m, nil
}
}
switch m.mode { switch m.mode {
case modeSwitcher: case modeSwitcher:
switch k { switch k {
@@ -461,11 +480,9 @@ func (m *Model) View() string {
content := m.renderLayout() content := m.renderLayout()
if m.automata != nil && m.width > 0 && m.height > 0 { if m.width > 0 && m.height > 0 {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
content, content,
lipgloss.WithWhitespaceChars(" "),
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
) )
} }
return content return content
@@ -474,7 +491,6 @@ func (m *Model) View() string {
func (m *Model) renderLayout() string { func (m *Model) renderLayout() string {
// top bar: power hints // top bar: power hints
top := m.renderPowerHints() top := m.renderPowerHints()
top = lipgloss.NewStyle().Width(80).Align(lipgloss.Left).Render(top)
// avatar + form side by side // avatar + form side by side
avatar := m.renderAvatar() avatar := m.renderAvatar()

View File

@@ -19,6 +19,7 @@ import (
"latchd/auth" "latchd/auth"
"latchd/config" "latchd/config"
"latchd/login" "latchd/login"
"latchd/session"
"latchd/tui/bg" "latchd/tui/bg"
) )
@@ -144,39 +145,37 @@ func spawnSession(res *login.Result) error {
return nil return nil
} }
log.Printf("spawning %s → %s (%s)", res.Username, res.Env.Title, res.Env.Kind) log.Printf("spawning %s → %s (%s)", res.Username, res.Env.Title, res.Env.Kind)
creds, err := auth.Validate(res.Username, res.Password, res.Config.PAMService) creds, err := auth.Validate(res.Username, res.Password, res.Config.PAMService)
if err != nil { if err != nil {
return fmt.Errorf("auth: %w", err) return fmt.Errorf("auth: %w", err)
} }
defer creds.CloseSession() defer creds.CloseSession()
pid, err := syscall.ForkExec("/proc/self/exe", []string{ if err := creds.OpenSession(); err != nil {
"latchd-session", log.Printf("pam session warning: %v", err)
"--user", creds.Username, }
"--uid", fmt.Sprintf("%d", creds.UID),
"--gid", fmt.Sprintf("%d", creds.PrimaryGID), session.SetBasicVariables(creds.Username, creds.HomeDir, creds.Shell, res.Config.InitialPath)
"--home", creds.HomeDir, if res.Env.Kind == "x11" {
"--shell", creds.Shell, session.SetDisplay(res.Config.X11.Display)
"--env-kind", res.Env.Kind, }
"--env-exec", res.Env.XinitrcPath, session.RemoveXDG()
"--tty", fmt.Sprintf("%d", res.Config.TTY), session.SetSessionParams(res.Env.Kind)
}, &syscall.ProcAttr{ session.SetSeatVars(res.Config.TTY)
Env: os.Environ(), session.SetSessionVars(creds.UID)
Dir: creds.HomeDir, session.SetXDGCommonPaths(creds.HomeDir)
Files: []uintptr{0, 1, 2},
Sys: &syscall.SysProcAttr{Setsid: true}, sess, err := session.Spawn(&res.Env, creds.UID, creds.PrimaryGID, creds.AllGIDs, &res.Config)
})
if err != nil { if err != nil {
return fmt.Errorf("fork: %w", err) return fmt.Errorf("spawn: %w", err)
} }
creds.OpenSession() err = sess.Wait()
if err != nil {
var ws syscall.WaitStatus log.Printf("session exited: %v", err)
if _, err := syscall.Wait4(pid, &ws, 0, nil); err != nil {
return fmt.Errorf("wait: %w", err)
} }
log.Printf("session pid %d exited %d", pid, ws.ExitStatus()) log.Printf("session pid %d ended", sess.PID())
return nil return nil
} }