Compare commits
13 Commits
32f7764f2b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1723ab53b8 | |||
| d7c9951d6b | |||
| a911adf284 | |||
| 5eae08c7ba | |||
| 6d7a756cd9 | |||
| f6d3acbfbf | |||
| 0050c5cf58 | |||
| f8156f85a5 | |||
| 1eb900c27c | |||
| c646c7a18b | |||
| eedde29e03 | |||
| 863ed82298 | |||
| 37a9ea0278 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ session.md
|
|||||||
build/*
|
build/*
|
||||||
examples/*
|
examples/*
|
||||||
things_todo.md
|
things_todo.md
|
||||||
|
ly/*
|
||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -24,18 +24,34 @@ 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`
|
||||||
|
- **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 visible at screen edges; form card covers the center
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -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,13 +151,15 @@ 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) |
|
| down / up | next/prev field |
|
||||||
|
| 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) |
|
||||||
|
|
||||||
## 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/
|
||||||
@@ -182,3 +184,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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +69,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 +114,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 +131,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 +140,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)}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,8 +171,10 @@ 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,
|
||||||
|
pamCB: h,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +192,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 {
|
||||||
@@ -196,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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -419,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().
|
||||||
@@ -461,28 +483,65 @@ 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 {
|
||||||
|
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,
|
||||||
lipgloss.WithWhitespaceChars(" "),
|
|
||||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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 = lipgloss.NewStyle().Width(80).Align(lipgloss.Left).Render(top)
|
// 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()
|
||||||
@@ -520,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 {
|
||||||
@@ -578,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")
|
||||||
}
|
}
|
||||||
@@ -588,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+" "))
|
||||||
}
|
}
|
||||||
@@ -620,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 {
|
||||||
|
|||||||
47
src/main.go
47
src/main.go
@@ -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,41 @@ 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},
|
session.SetDesktopVars(res.Env.DesktopNames, res.Env.SessionDesktop)
|
||||||
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()
|
utmpx := auth.AddUtmpx(creds.Username, res.Config.TTY, uint32(sess.PID()))
|
||||||
|
defer utmpx.Close()
|
||||||
|
|
||||||
var ws syscall.WaitStatus
|
err = sess.Wait()
|
||||||
if _, err := syscall.Wait4(pid, &ws, 0, nil); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wait: %w", err)
|
log.Printf("session exited: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("session pid %d exited %d", pid, ws.ExitStatus())
|
log.Printf("session pid %d ended", sess.PID())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PostLoginEnv struct {
|
type PostLoginEnv struct {
|
||||||
Kind string // "x11", "wayland", "tty"
|
Kind string // "x11", "wayland", "tty"
|
||||||
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 {
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DesktopEntry struct {
|
type DesktopEntry struct {
|
||||||
Name string
|
Name string
|
||||||
Exec string
|
Exec string
|
||||||
Hidden bool
|
DesktopNames string
|
||||||
NoDisplay bool
|
Hidden bool
|
||||||
|
NoDisplay bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDesktopEntry(path string) (*DesktopEntry, error) {
|
func parseDesktopEntry(path string) (*DesktopEntry, error) {
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user