feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
This commit is contained in:
9
legacy/README.md
Normal file
9
legacy/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Legacy Go implementation
|
||||
|
||||
The original Go implementation remains in `legacy/go/` as a reference and
|
||||
compatibility target while the production binary is built from the Rust
|
||||
workspace. It is not part of the default build, install, service, release, or
|
||||
Docker test paths. Run `make legacy-build` only when comparing behavior during
|
||||
the migration; the target is intentionally labelled as quarantined.
|
||||
|
||||
New security fixes and features belong in the Rust crates under `crates/`.
|
||||
81
legacy/go/auth/faillock.go
Normal file
81
legacy/go/auth/faillock.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// auth — faillock integration.
|
||||
//
|
||||
// Reads /etc/security/faillock.conf for deny count, executes
|
||||
// faillock --user to get tally, supports --reset via admin bypass.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FaillockStatus struct {
|
||||
Enabled bool
|
||||
Failures int
|
||||
MaxTries int
|
||||
Remaining int
|
||||
LockedOut bool
|
||||
}
|
||||
|
||||
func GetFaillockStatus(user string) (FaillockStatus, error) {
|
||||
s := FaillockStatus{}
|
||||
|
||||
data, err := os.ReadFile("/etc/security/faillock.conf")
|
||||
if err != nil {
|
||||
return s, nil
|
||||
}
|
||||
s.Enabled = true
|
||||
s.MaxTries = 3
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "deny") {
|
||||
parts := strings.Split(line, "=")
|
||||
if len(parts) == 2 {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
|
||||
s.MaxTries = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command("faillock", "--user", user)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return s, fmt.Errorf("faillock: %w", err)
|
||||
}
|
||||
|
||||
s.Failures = countFaillockLines(out.String())
|
||||
s.Remaining = s.MaxTries - s.Failures
|
||||
if s.Remaining < 0 {
|
||||
s.Remaining = 0
|
||||
}
|
||||
s.LockedOut = s.Remaining <= 0
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func ResetFaillock(user string) error {
|
||||
return exec.Command("faillock", "--user", user, "--reset").Run()
|
||||
}
|
||||
|
||||
func countFaillockLines(out string) int {
|
||||
n := 0
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "When") ||
|
||||
strings.HasPrefix(line, "Source") || strings.HasPrefix(line, "Date") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, ":") {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
233
legacy/go/auth/pam.go
Normal file
233
legacy/go/auth/pam.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// auth — PAM authentication with CGO bindings.
|
||||
//
|
||||
// Uses runtime.LockOSThread to prevent goroutine migration during
|
||||
// PAM transactions, as PAM modules rely on thread-local storage.
|
||||
//
|
||||
// Mirrors vendor/src/auth/pam.rs and vendor/src/auth/mod.rs.
|
||||
package auth
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -lpam
|
||||
#include <security/pam_appl.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// Match the //export-generated signature.
|
||||
extern int latchd_pam_conv(int, struct pam_message **,
|
||||
struct pam_response **, void *);
|
||||
|
||||
// Store a uintptr_t handle in C heap so the Go GC never sees a Go pointer.
|
||||
static void *storeHandle(uintptr_t h) {
|
||||
uintptr_t *p = malloc(sizeof(uintptr_t));
|
||||
if (p) *p = h;
|
||||
return p;
|
||||
}
|
||||
static uintptr_t loadHandle(void *p) {
|
||||
return *(uintptr_t *)p;
|
||||
}
|
||||
|
||||
static struct pam_conv makePAMConv(void *data) {
|
||||
struct pam_conv c;
|
||||
c.conv = (int (*)(int, const struct pam_message **,
|
||||
struct pam_response **, void *))latchd_pam_conv;
|
||||
c.appdata_ptr = data;
|
||||
return c;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
ouser "os/user"
|
||||
"runtime"
|
||||
"runtime/cgo"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type AuthError struct {
|
||||
Kind int
|
||||
Msg string
|
||||
}
|
||||
|
||||
const (
|
||||
ErrPAMService = iota
|
||||
ErrAccountValidation
|
||||
ErrHomeDirInvalid
|
||||
ErrShellInvalid
|
||||
ErrUsernameNotFound
|
||||
ErrSessionOpen
|
||||
)
|
||||
|
||||
func (e *AuthError) Error() string { return e.Msg }
|
||||
|
||||
type Credentials struct {
|
||||
Username string
|
||||
UID uint32
|
||||
PrimaryGID uint32
|
||||
AllGIDs []uint32
|
||||
HomeDir string
|
||||
Shell string
|
||||
pamHandle *C.pam_handle_t
|
||||
pamData unsafe.Pointer
|
||||
pamCB cgo.Handle
|
||||
}
|
||||
|
||||
//export latchd_pam_conv
|
||||
func latchd_pam_conv(numMsg C.int, msg **C.struct_pam_message, resp **C.struct_pam_response, data unsafe.Pointer) C.int {
|
||||
h := cgo.Handle(C.loadHandle(data))
|
||||
pw := h.Value().(string)
|
||||
|
||||
r := (*C.struct_pam_response)(C.calloc(C.ulong(numMsg), C.sizeof_struct_pam_response))
|
||||
if r == nil {
|
||||
return C.PAM_BUF_ERR
|
||||
}
|
||||
*resp = r
|
||||
|
||||
msgSlice := unsafe.Slice(msg, int(numMsg))
|
||||
respSlice := unsafe.Slice(r, int(numMsg))
|
||||
|
||||
for i := 0; i < int(numMsg); i++ {
|
||||
switch msgSlice[i].msg_style {
|
||||
case C.PAM_PROMPT_ECHO_OFF, C.PAM_PROMPT_ECHO_ON:
|
||||
c := C.CString(pw)
|
||||
if c == nil {
|
||||
return C.PAM_BUF_ERR
|
||||
}
|
||||
respSlice[i].resp = c
|
||||
respSlice[i].resp_retcode = 0
|
||||
default:
|
||||
respSlice[i].resp = nil
|
||||
respSlice[i].resp_retcode = 0
|
||||
}
|
||||
}
|
||||
return C.PAM_SUCCESS
|
||||
}
|
||||
|
||||
func Validate(user, pass, service string) (*Credentials, error) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
cSvc := C.CString(service)
|
||||
cUser := C.CString(user)
|
||||
defer C.free(unsafe.Pointer(cSvc))
|
||||
defer C.free(unsafe.Pointer(cUser))
|
||||
|
||||
h := cgo.NewHandle(pass)
|
||||
|
||||
cData := C.storeHandle(C.uintptr_t(h))
|
||||
|
||||
conv := C.makePAMConv(cData)
|
||||
|
||||
var pamh *C.pam_handle_t
|
||||
ret := C.pam_start(cSvc, cUser, &conv, &pamh)
|
||||
if ret != C.PAM_SUCCESS {
|
||||
h.Delete()
|
||||
C.free(cData)
|
||||
return nil, &AuthError{ErrPAMService, fmt.Sprintf("pam_start: %s", pamErr(pamh, ret))}
|
||||
}
|
||||
|
||||
ret = C.pam_authenticate(pamh, 0)
|
||||
if ret != C.PAM_SUCCESS {
|
||||
msg := fmt.Sprintf("auth failed: %s", pamErr(pamh, ret))
|
||||
C.pam_end(pamh, ret)
|
||||
h.Delete()
|
||||
C.free(cData)
|
||||
return nil, &AuthError{ErrAccountValidation, msg}
|
||||
}
|
||||
|
||||
ret = C.pam_acct_mgmt(pamh, 0)
|
||||
if ret != C.PAM_SUCCESS {
|
||||
msg := fmt.Sprintf("account: %s", pamErr(pamh, ret))
|
||||
C.pam_end(pamh, ret)
|
||||
h.Delete()
|
||||
C.free(cData)
|
||||
return nil, &AuthError{ErrAccountValidation, msg}
|
||||
}
|
||||
|
||||
u, err := ouser.Lookup(user)
|
||||
if err != nil {
|
||||
C.pam_end(pamh, ret)
|
||||
h.Delete()
|
||||
C.free(cData)
|
||||
return nil, &AuthError{ErrUsernameNotFound, fmt.Sprintf("lookup %s: %v", user, err)}
|
||||
}
|
||||
|
||||
uid := atou32(u.Uid)
|
||||
gid := atou32(u.Gid)
|
||||
gids := []uint32{gid}
|
||||
if ids, err := u.GroupIds(); err == nil {
|
||||
for _, s := range ids {
|
||||
g := atou32(s)
|
||||
if g != gid {
|
||||
gids = append(gids, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Credentials{
|
||||
Username: user,
|
||||
UID: uid,
|
||||
PrimaryGID: gid,
|
||||
AllGIDs: gids,
|
||||
HomeDir: u.HomeDir,
|
||||
Shell: shellFromPasswd(uid),
|
||||
pamHandle: pamh,
|
||||
pamData: cData,
|
||||
pamCB: h,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Credentials) OpenSession() error {
|
||||
ret := C.pam_open_session(c.pamHandle, 0)
|
||||
if ret != C.PAM_SUCCESS {
|
||||
return &AuthError{ErrSessionOpen, fmt.Sprintf("session: %s", pamErr(c.pamHandle, ret))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Credentials) CloseSession() {
|
||||
if c.pamHandle != nil {
|
||||
C.pam_close_session(c.pamHandle, 0)
|
||||
C.pam_end(c.pamHandle, 0)
|
||||
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 {
|
||||
return C.GoString(C.pam_strerror(h, e))
|
||||
}
|
||||
|
||||
func atou32(s string) uint32 {
|
||||
var n uint32
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
n = n*10 + uint32(c-'0')
|
||||
}
|
||||
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"
|
||||
}
|
||||
77
legacy/go/auth/utmpx.go
Normal file
77
legacy/go/auth/utmpx.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// auth — UTMPX user accounting (glibc only).
|
||||
//
|
||||
// Mirrors vendor/src/auth/utmpx.rs.
|
||||
package auth
|
||||
|
||||
/*
|
||||
#include <utmpx.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "utmpx_time.h"
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type UtmpxSession struct {
|
||||
ok bool
|
||||
username string
|
||||
tty uint8
|
||||
}
|
||||
|
||||
func AddUtmpx(user string, tty uint8, pid uint32) *UtmpxSession {
|
||||
s := &UtmpxSession{username: user, tty: tty}
|
||||
|
||||
cUser := C.CString(user)
|
||||
defer C.free(unsafe.Pointer(cUser))
|
||||
|
||||
var ut C.struct_utmpx
|
||||
C.memset(unsafe.Pointer(&ut), 0, C.sizeof_struct_utmpx)
|
||||
|
||||
ut.ut_type = C.USER_PROCESS
|
||||
ut.ut_pid = C.pid_t(pid)
|
||||
|
||||
n := len(user)
|
||||
if n > 32 {
|
||||
n = 32
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
ut.ut_user[i] = C.char(user[i])
|
||||
}
|
||||
|
||||
ttyCh := byte('0' + tty)
|
||||
ut.ut_line[0] = 't'
|
||||
ut.ut_line[1] = 't'
|
||||
ut.ut_line[2] = 'y'
|
||||
ut.ut_line[3] = C.char(ttyCh)
|
||||
ut.ut_id[0] = C.char(ttyCh)
|
||||
|
||||
us := time.Now().UnixMicro()
|
||||
C.latchd_set_utmpx_time(&ut, C.int64_t(us/1_000_000), C.int32_t(us%1_000_000))
|
||||
|
||||
C.setutxent()
|
||||
C.pututxline(&ut)
|
||||
|
||||
s.ok = true
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *UtmpxSession) Close() {
|
||||
if !s.ok {
|
||||
return
|
||||
}
|
||||
|
||||
var ut C.struct_utmpx
|
||||
C.memset(unsafe.Pointer(&ut), 0, C.sizeof_struct_utmpx)
|
||||
ut.ut_type = C.DEAD_PROCESS
|
||||
|
||||
C.setutxent()
|
||||
C.pututxline(&ut)
|
||||
C.endutxent()
|
||||
|
||||
s.ok = false
|
||||
}
|
||||
10
legacy/go/auth/utmpx_time.h
Normal file
10
legacy/go/auth/utmpx_time.h
Normal file
@@ -0,0 +1,10 @@
|
||||
// Helper: assign seconds + microseconds to utmpx's ut_tv field.
|
||||
// The C compiler handles the type conversion, so this works whether
|
||||
// ut_tv is struct timeval (long tv_sec/tv_usec) or the compat struct
|
||||
// (__uint32_t tv_sec / __int32_t tv_usec).
|
||||
|
||||
#include <stdint.h>
|
||||
static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) {
|
||||
ut->ut_tv.tv_sec = sec;
|
||||
ut->ut_tv.tv_usec = usec;
|
||||
}
|
||||
309
legacy/go/config/config.go
Normal file
309
legacy/go/config/config.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type FocusBehaviour string
|
||||
|
||||
const (
|
||||
FocusFirstNonCached FocusBehaviour = "default"
|
||||
FocusNone FocusBehaviour = "no-focus"
|
||||
FocusEnvironment FocusBehaviour = "environment"
|
||||
FocusUsername FocusBehaviour = "username"
|
||||
FocusPassword FocusBehaviour = "password"
|
||||
)
|
||||
|
||||
type ShellLoginFlag string
|
||||
|
||||
const (
|
||||
ShellLoginNone ShellLoginFlag = "none"
|
||||
ShellLoginShort ShellLoginFlag = "short"
|
||||
ShellLoginLong ShellLoginFlag = "long"
|
||||
)
|
||||
|
||||
type BackgroundStyle struct {
|
||||
Color string `toml:"color"`
|
||||
ShowBorder bool `toml:"show_border"`
|
||||
BorderColor string `toml:"border_color"`
|
||||
}
|
||||
|
||||
type BackgroundConfig struct {
|
||||
ShowBackground bool `toml:"show_background"`
|
||||
Style BackgroundStyle `toml:"style"`
|
||||
}
|
||||
|
||||
type PowerControl struct {
|
||||
Hint string `toml:"hint"`
|
||||
HintColor string `toml:"hint_color"`
|
||||
HintModifiers string `toml:"hint_modifiers"`
|
||||
Key string `toml:"key"`
|
||||
Cmd string `toml:"cmd"`
|
||||
}
|
||||
|
||||
type PowerControlConfig struct {
|
||||
HintMargin uint16 `toml:"hint_margin"`
|
||||
BaseEntries []PowerControl `toml:"base_entries"`
|
||||
Entries []PowerControl `toml:"entries"`
|
||||
}
|
||||
|
||||
type SwitcherConfig struct {
|
||||
SwitcherVisibility string `toml:"switcher_visibility"`
|
||||
ToggleHint string `toml:"toggle_hint"`
|
||||
ToggleHintColor string `toml:"toggle_hint_color"`
|
||||
ToggleHintModifiers string `toml:"toggle_hint_modifiers"`
|
||||
IncludeTTYShell bool `toml:"include_tty_shell"`
|
||||
Remember bool `toml:"remember"`
|
||||
ShowMovers bool `toml:"show_movers"`
|
||||
MoverColor string `toml:"mover_color"`
|
||||
MoverColorFocused string `toml:"mover_color_focused"`
|
||||
MoverModifiers string `toml:"mover_modifiers"`
|
||||
MoverModifiersFocused string `toml:"mover_modifiers_focused"`
|
||||
LeftMover string `toml:"left_mover"`
|
||||
RightMover string `toml:"right_mover"`
|
||||
MoverMargin uint16 `toml:"mover_margin"`
|
||||
ShowNeighbours bool `toml:"show_neighbours"`
|
||||
NeighbourColor string `toml:"neighbour_color"`
|
||||
NeighbourColorFocused string `toml:"neighbour_color_focused"`
|
||||
NeighbourModifiers string `toml:"neighbour_modifiers"`
|
||||
NeighbourModifiersFocused string `toml:"neighbour_modifiers_focused"`
|
||||
NeighbourMargin uint16 `toml:"neighbour_margin"`
|
||||
SelectedColor string `toml:"selected_color"`
|
||||
SelectedColorFocused string `toml:"selected_color_focused"`
|
||||
SelectedModifiers string `toml:"selected_modifiers"`
|
||||
SelectedModifiersFocused string `toml:"selected_modifiers_focused"`
|
||||
MaxDisplayLength uint16 `toml:"max_display_length"`
|
||||
NoEnvsText string `toml:"no_envs_text"`
|
||||
NoEnvsColor string `toml:"no_envs_color"`
|
||||
NoEnvsColorFocused string `toml:"no_envs_color_focused"`
|
||||
NoEnvsModifiers string `toml:"no_envs_modifiers"`
|
||||
NoEnvsModifiersFocused string `toml:"no_envs_modifiers_focused"`
|
||||
}
|
||||
|
||||
type InputFieldStyle struct {
|
||||
ShowTitle bool `toml:"show_title"`
|
||||
Title string `toml:"title"`
|
||||
ShowBorder bool `toml:"show_border"`
|
||||
TitleColor string `toml:"title_color"`
|
||||
TitleColorFocused string `toml:"title_color_focused"`
|
||||
ContentColor string `toml:"content_color"`
|
||||
ContentColorFocused string `toml:"content_color_focused"`
|
||||
BorderColor string `toml:"border_color"`
|
||||
BorderColorFocused string `toml:"border_color_focused"`
|
||||
UseMaxWidth bool `toml:"use_max_width"`
|
||||
MaxWidth uint16 `toml:"max_width"`
|
||||
}
|
||||
|
||||
type UsernameFieldConfig struct {
|
||||
Remember bool `toml:"remember"`
|
||||
Style InputFieldStyle `toml:"style"`
|
||||
}
|
||||
|
||||
type PasswordFieldConfig struct {
|
||||
ReplacementChar string `toml:"content_replacement_character"`
|
||||
Style InputFieldStyle `toml:"style"`
|
||||
}
|
||||
|
||||
type X11Config struct {
|
||||
Display string `toml:"x11_display"`
|
||||
ServerTimeoutSecs uint16 `toml:"xserver_timeout_secs"`
|
||||
ServerLogPath string `toml:"xserver_log_path"`
|
||||
ServerPath string `toml:"xserver_path"`
|
||||
XauthPath string `toml:"xauth_path"`
|
||||
ScriptsPath string `toml:"scripts_path"`
|
||||
SetupPath string `toml:"xsetup_path"`
|
||||
SessionsPath string `toml:"xsessions_path"`
|
||||
}
|
||||
|
||||
type WaylandConfig struct {
|
||||
ScriptsPath string `toml:"scripts_path"`
|
||||
SessionsPath string `toml:"wayland_sessions_path"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
TTY uint8 `toml:"tty"`
|
||||
MainLogPath string `toml:"main_log_path"`
|
||||
ClientLogPath string `toml:"client_log_path"`
|
||||
CachePath string `toml:"cache_path"`
|
||||
DoLog bool `toml:"do_log"`
|
||||
PAMService string `toml:"pam_service"`
|
||||
SystemShell string `toml:"system_shell"`
|
||||
InitialPath string `toml:"initial_path"`
|
||||
ShellLoginFlag ShellLoginFlag `toml:"shell_login_flag"`
|
||||
FocusBehaviour FocusBehaviour `toml:"focus_behaviour"`
|
||||
Background BackgroundConfig `toml:"background"`
|
||||
PowerControls PowerControlConfig `toml:"power_controls"`
|
||||
Switcher SwitcherConfig `toml:"environment_switcher"`
|
||||
Username UsernameFieldConfig `toml:"username_field"`
|
||||
Password PasswordFieldConfig `toml:"password_field"`
|
||||
X11 X11Config `toml:"x11"`
|
||||
Wayland WaylandConfig `toml:"wayland"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
var c Config
|
||||
if err := toml.Unmarshal([]byte(defaultTOML), &c); err != nil {
|
||||
panic("default config: " + err.Error())
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func LoadPartial(filename string, vars map[string]string, dst *Config) error {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
if vars != nil {
|
||||
raw = []byte(resolveVars(string(raw), vars))
|
||||
}
|
||||
return toml.Unmarshal(raw, dst)
|
||||
}
|
||||
|
||||
func LoadVariables(filename string) (map[string]string, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
// Parse a flat TOML table of string values.
|
||||
vars := make(map[string]string)
|
||||
var table map[string]any
|
||||
if err := toml.Unmarshal(raw, &table); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", filename, err)
|
||||
}
|
||||
for k, v := range table {
|
||||
vars[k] = fmt.Sprint(v)
|
||||
}
|
||||
return vars, nil
|
||||
}
|
||||
|
||||
// ── variable substitution ───────────────────────────────────────────────
|
||||
|
||||
func resolveVars(in string, vars map[string]string) string {
|
||||
for k, v := range vars {
|
||||
in = strings.ReplaceAll(in, "$"+k, v)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// ── embedded default config ─────────────────────────────────────────────
|
||||
|
||||
const defaultTOML = `
|
||||
tty = 2
|
||||
main_log_path = "/var/log/latchd.log"
|
||||
client_log_path = "/var/log/latchd.client.log"
|
||||
cache_path = "/var/cache/latchd"
|
||||
do_log = true
|
||||
pam_service = "latchd"
|
||||
system_shell = "/bin/sh"
|
||||
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin"
|
||||
shell_login_flag = "short"
|
||||
focus_behaviour = "default"
|
||||
|
||||
[background]
|
||||
show_background = true
|
||||
|
||||
[background.style]
|
||||
color = "black"
|
||||
show_border = true
|
||||
border_color = "white"
|
||||
|
||||
[power_controls]
|
||||
hint_margin = 2
|
||||
entries = []
|
||||
|
||||
[[power_controls.base_entries]]
|
||||
hint = "Shutdown"
|
||||
hint_color = "dark gray"
|
||||
hint_modifiers = ""
|
||||
key = "F1"
|
||||
cmd = "systemctl poweroff -l"
|
||||
|
||||
[[power_controls.base_entries]]
|
||||
hint = "Reboot"
|
||||
hint_color = "dark gray"
|
||||
hint_modifiers = ""
|
||||
key = "F2"
|
||||
cmd = "systemctl reboot -l"
|
||||
|
||||
[environment_switcher]
|
||||
switcher_visibility = "visible"
|
||||
toggle_hint = "Switcher %key%"
|
||||
toggle_hint_color = "dark gray"
|
||||
toggle_hint_modifiers = ""
|
||||
include_tty_shell = false
|
||||
remember = true
|
||||
show_movers = true
|
||||
mover_color = "dark gray"
|
||||
mover_modifiers = ""
|
||||
mover_color_focused = "orange"
|
||||
mover_modifiers_focused = "bold"
|
||||
left_mover = "<"
|
||||
right_mover = ">"
|
||||
mover_margin = 1
|
||||
show_neighbours = true
|
||||
neighbour_color = "dark gray"
|
||||
neighbour_modifiers = ""
|
||||
neighbour_color_focused = "gray"
|
||||
neighbour_modifiers_focused = ""
|
||||
neighbour_margin = 1
|
||||
selected_color = "gray"
|
||||
selected_modifiers = "underlined"
|
||||
selected_color_focused = "white"
|
||||
selected_modifiers_focused = "bold"
|
||||
max_display_length = 8
|
||||
no_envs_text = "No environments..."
|
||||
no_envs_color = "white"
|
||||
no_envs_modifiers = ""
|
||||
no_envs_color_focused = "red"
|
||||
no_envs_modifiers_focused = ""
|
||||
|
||||
[username_field]
|
||||
remember = true
|
||||
|
||||
[username_field.style]
|
||||
show_title = true
|
||||
title = "Login"
|
||||
title_color = "white"
|
||||
content_color = "white"
|
||||
title_color_focused = "orange"
|
||||
content_color_focused = "orange"
|
||||
show_border = true
|
||||
border_color = "white"
|
||||
border_color_focused = "orange"
|
||||
use_max_width = true
|
||||
max_width = 48
|
||||
|
||||
[password_field]
|
||||
content_replacement_character = "*"
|
||||
|
||||
[password_field.style]
|
||||
show_title = true
|
||||
title = "Password"
|
||||
title_color = "white"
|
||||
content_color = "white"
|
||||
title_color_focused = "orange"
|
||||
content_color_focused = "orange"
|
||||
show_border = true
|
||||
border_color = "white"
|
||||
border_color_focused = "orange"
|
||||
use_max_width = true
|
||||
max_width = 48
|
||||
|
||||
[x11]
|
||||
x11_display = ":1"
|
||||
xserver_timeout_secs = 60
|
||||
xserver_log_path = "/var/log/latchd.xorg.log"
|
||||
xserver_path = "/usr/bin/X"
|
||||
xauth_path = "/usr/bin/xauth"
|
||||
scripts_path = "/etc/latchd/wms"
|
||||
xsetup_path = "/etc/latchd/xsetup.sh"
|
||||
xsessions_path = "/usr/share/xsessions"
|
||||
|
||||
[wayland]
|
||||
scripts_path = "/etc/latchd/wayland"
|
||||
wayland_sessions_path = "/usr/share/wayland-sessions"
|
||||
`
|
||||
30
legacy/go/go.mod
Normal file
30
legacy/go/go.mod
Normal file
@@ -0,0 +1,30 @@
|
||||
module latchd
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/charmbracelet/bubbletea v0.26.6
|
||||
github.com/charmbracelet/lipgloss v0.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.1.2 // indirect
|
||||
github.com/charmbracelet/x/input v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/term v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.1.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
47
legacy/go/go.sum
Normal file
47
legacy/go/go.sum
Normal file
@@ -0,0 +1,47 @@
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
|
||||
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
|
||||
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
|
||||
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
|
||||
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
|
||||
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
|
||||
github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
|
||||
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
|
||||
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
|
||||
github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4=
|
||||
github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
855
legacy/go/login/login.go
Normal file
855
legacy/go/login/login.go
Normal file
@@ -0,0 +1,855 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
cardBg = lipgloss.Color("0") // black card background
|
||||
cardPad = 2
|
||||
|
||||
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.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,
|
||||
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 {
|
||||
clock := m.renderClock()
|
||||
|
||||
// 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 := m.renderAvatar()
|
||||
form := m.renderForm()
|
||||
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
|
||||
|
||||
// DE switcher with specifier label
|
||||
sw := m.renderSwitcher()
|
||||
|
||||
// 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 lipgloss.NewStyle().
|
||||
Background(cardBg).
|
||||
Padding(cardPad, cardPad+2).
|
||||
Render(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) 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 {
|
||||
return swNeighbour.Align(lipgloss.Center).Render("no desktop environments found")
|
||||
}
|
||||
|
||||
cfg := m.cfg.Switcher
|
||||
w := int(cfg.MaxDisplayLength)
|
||||
|
||||
var parts []string
|
||||
|
||||
// session specifier (like "x11", "wayland")
|
||||
spec := swDot.Render(m.envs[m.selIdx].Kind + " ")
|
||||
|
||||
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, spec, 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)
|
||||
})
|
||||
}
|
||||
263
legacy/go/main.go
Normal file
263
legacy/go/main.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// latchd — TUI display/login manager for GNU/Linux and BSD.
|
||||
//
|
||||
// Supports TTY, X11, and Wayland sessions with PAM authentication.
|
||||
// Go rewrite of Lemurs (Rust source under vendor/).
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"latchd/auth"
|
||||
"latchd/config"
|
||||
"latchd/login"
|
||||
"latchd/session"
|
||||
"latchd/tui/bg"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "0.1.0"
|
||||
Commit = "unknown"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
|
||||
const defaultConfig = "/etc/latchd/config.toml"
|
||||
const defaultVars = "/etc/latchd/variables.toml"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
configPath string
|
||||
varsPath string
|
||||
ttyNum int
|
||||
preview bool
|
||||
noLog bool
|
||||
showConf bool
|
||||
printVer bool
|
||||
xsessions string
|
||||
wlsessions string
|
||||
initPath string
|
||||
)
|
||||
flag.StringVar(&configPath, "config", defaultConfig, "")
|
||||
flag.StringVar(&configPath, "c", defaultConfig, "")
|
||||
flag.StringVar(&varsPath, "variables", defaultVars, "")
|
||||
flag.StringVar(&varsPath, "v", defaultVars, "")
|
||||
flag.IntVar(&ttyNum, "tty", 0, "")
|
||||
flag.BoolVar(&preview, "preview", false, "")
|
||||
flag.BoolVar(&noLog, "no-log", false, "")
|
||||
flag.BoolVar(&showConf, "show-config", false, "")
|
||||
flag.BoolVar(&printVer, "version", false, "")
|
||||
flag.BoolVar(&printVer, "V", false, "")
|
||||
flag.StringVar(&xsessions, "xsessions", "", "")
|
||||
flag.StringVar(&wlsessions, "wlsessions", "", "")
|
||||
flag.StringVar(&initPath, "initial-path", "", "")
|
||||
flag.Parse()
|
||||
|
||||
if printVer {
|
||||
fmt.Printf("latchd %s commit %s built %s\n", Version, Commit, BuildTime)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
cfg := config.Default()
|
||||
if initPath != "" {
|
||||
cfg.InitialPath = initPath
|
||||
}
|
||||
if xsessions != "" {
|
||||
cfg.X11.SessionsPath = xsessions
|
||||
}
|
||||
if wlsessions != "" {
|
||||
cfg.Wayland.SessionsPath = wlsessions
|
||||
}
|
||||
vars, _ := config.LoadVariables(varsPath)
|
||||
if err := config.LoadPartial(configPath, vars, &cfg); err != nil {
|
||||
if configPath != defaultConfig {
|
||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if showConf {
|
||||
fmt.Printf("tty = %d\npam_service = %q\nshell = %q\nfocus = %q\n",
|
||||
cfg.TTY, cfg.PAMService, cfg.SystemShell, cfg.FocusBehaviour)
|
||||
os.Exit(0)
|
||||
}
|
||||
if !noLog && !preview {
|
||||
setupLog(cfg.MainLogPath)
|
||||
}
|
||||
if !preview {
|
||||
if _, ok := os.LookupEnv("XDG_SESSION_TYPE"); ok {
|
||||
fmt.Fprintln(os.Stderr, "latchd: already in a session — use --preview")
|
||||
os.Exit(1)
|
||||
}
|
||||
if os.Getuid() != 0 {
|
||||
fmt.Fprintf(os.Stderr, "latchd: must be root (uid %d)\n", os.Getuid())
|
||||
os.Exit(1)
|
||||
}
|
||||
if ttyNum != 0 {
|
||||
cfg.TTY = uint8(ttyNum)
|
||||
}
|
||||
if err := chvt(int(cfg.TTY)); err != nil {
|
||||
log.Printf("chvt %d: %v", cfg.TTY, err)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
res := launchUI(cfg, preview)
|
||||
if res == nil {
|
||||
break
|
||||
}
|
||||
if err := spawnSession(res); err != nil {
|
||||
log.Printf("session: %v", err)
|
||||
}
|
||||
if !preview {
|
||||
chvt(int(cfg.TTY))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func launchUI(cfg config.Config, preview bool) *login.Result {
|
||||
m := login.New(cfg, preview)
|
||||
if cfg.Background.ShowBackground {
|
||||
m.SetAutomata(bg.NewAutomata(80, 24))
|
||||
}
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
final, err := p.Run()
|
||||
if err != nil {
|
||||
log.Printf("tui: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
lm, ok := final.(*login.Model)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return lm.Result()
|
||||
}
|
||||
|
||||
func spawnSession(res *login.Result) error {
|
||||
if res.Preview {
|
||||
log.Println("preview — skipping real session")
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: %w", err)
|
||||
}
|
||||
defer creds.CloseSession()
|
||||
|
||||
if err := creds.OpenSession(); err != nil {
|
||||
log.Printf("pam session warning: %v", err)
|
||||
}
|
||||
|
||||
session.SetBasicVariables(creds.Username, creds.HomeDir, creds.Shell, res.Config.InitialPath)
|
||||
if res.Env.Kind == "x11" {
|
||||
session.SetDisplay(res.Config.X11.Display)
|
||||
}
|
||||
session.RemoveXDG()
|
||||
session.SetSessionParams(res.Env.Kind)
|
||||
session.SetSeatVars(res.Config.TTY)
|
||||
session.SetSessionVars(creds.UID)
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("spawn: %w", err)
|
||||
}
|
||||
|
||||
utmpx := auth.AddUtmpx(creds.Username, res.Config.TTY, uint32(sess.PID()))
|
||||
defer utmpx.Close()
|
||||
|
||||
err = sess.Wait()
|
||||
if err != nil {
|
||||
log.Printf("session exited: %v", err)
|
||||
}
|
||||
log.Printf("session pid %d ended", sess.PID())
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupLog(path string) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "log %s: %v\n", path, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.SetOutput(io.MultiWriter(f, os.Stderr))
|
||||
log.SetFlags(log.LstdFlags)
|
||||
}
|
||||
|
||||
// ── chvt ────────────────────────────────────────────────────────────────
|
||||
|
||||
const vtA = 0x5606
|
||||
const vtW = 0x5607
|
||||
const kbt = 0x4B33
|
||||
const kb1 = 0x02
|
||||
const kb2 = 0x01
|
||||
|
||||
func isCon(fd uintptr) bool {
|
||||
var a int32
|
||||
_, _, e := syscall.Syscall(syscall.SYS_IOCTL, fd, kbt, uintptr(unsafe.Pointer(&a)))
|
||||
return e == 0 && (a == kb1 || a == kb2)
|
||||
}
|
||||
|
||||
func openCon(fn string) (int, error) {
|
||||
for _, fl := range []int{os.O_RDWR, os.O_RDONLY, os.O_WRONLY} {
|
||||
fd, err := syscall.Open(fn, fl, 0)
|
||||
if err != nil {
|
||||
if err == syscall.EACCES {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if isCon(uintptr(fd)) {
|
||||
return fd, nil
|
||||
}
|
||||
syscall.Close(fd)
|
||||
}
|
||||
return 0, fmt.Errorf("openCon: %s", fn)
|
||||
}
|
||||
|
||||
func getCon() (int, error) {
|
||||
for _, p := range []string{"/dev/tty", "/dev/tty0", "/dev/vc/0", "/dev/console"} {
|
||||
if fd, err := openCon(p); err == nil {
|
||||
return fd, nil
|
||||
}
|
||||
}
|
||||
for fd := 0; fd < 3; fd++ {
|
||||
if isCon(uintptr(fd)) {
|
||||
return fd, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no console")
|
||||
}
|
||||
|
||||
func chvt(n int) error {
|
||||
fd, err := getCon()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), vtA, uintptr(n)); e != 0 {
|
||||
return fmt.Errorf("VT_ACTIVATE")
|
||||
}
|
||||
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), vtW, uintptr(n)); e != 0 {
|
||||
return fmt.Errorf("VT_WAITACTIVE")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT)
|
||||
go func() {
|
||||
<-ch
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
73
legacy/go/session/env.go
Normal file
73
legacy/go/session/env.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// session — environment variable setup for latchd sessions.
|
||||
//
|
||||
// Mirrors vendor/src/post_login/env_variables.rs.
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func SetBasicVariables(user, home, shell, path string) {
|
||||
os.Chdir(home)
|
||||
os.Setenv("HOME", home)
|
||||
os.Setenv("SHELL", shell)
|
||||
os.Setenv("USER", user)
|
||||
os.Setenv("LOGNAME", user)
|
||||
os.Setenv("PATH", path)
|
||||
}
|
||||
|
||||
func SetDisplay(display string) {
|
||||
os.Setenv("DISPLAY", display)
|
||||
}
|
||||
|
||||
func RemoveXDG() {
|
||||
for _, v := range []string{
|
||||
"XDG_SESSION_CLASS", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP",
|
||||
"XDG_SEAT", "XDG_VTNR", "XDG_RUNTIME_DIR", "XDG_SESSION_ID",
|
||||
"XDG_CONFIG_DIR", "XDG_CACHE_HOME", "XDG_DATA_HOME",
|
||||
"XDG_STATE_HOME", "XDG_DATA_DIRS", "XDG_CONFIG_DIRS",
|
||||
} {
|
||||
os.Unsetenv(v)
|
||||
}
|
||||
}
|
||||
|
||||
func SetSessionParams(sessionType string) {
|
||||
os.Setenv("XDG_SESSION_CLASS", "user")
|
||||
os.Setenv("XDG_SESSION_TYPE", sessionType)
|
||||
}
|
||||
|
||||
func SetSeatVars(tty uint8) {
|
||||
setOrOwn("XDG_SEAT", "seat0")
|
||||
setOrOwn("XDG_VTNR", strconv.Itoa(int(tty)))
|
||||
}
|
||||
|
||||
func SetSessionVars(uid uint32) {
|
||||
setOrOwn("XDG_RUNTIME_DIR", fmt.Sprintf("/run/user/%d", uid))
|
||||
setOrOwn("XDG_SESSION_ID", "1")
|
||||
}
|
||||
|
||||
func SetXDGCommonPaths(home string) {
|
||||
setOrOwn("XDG_CONFIG_HOME", home+"/.config")
|
||||
setOrOwn("XDG_CACHE_HOME", home+"/.cache")
|
||||
setOrOwn("XDG_DATA_HOME", home+"/.local/share")
|
||||
setOrOwn("XDG_STATE_HOME", home+"/.local/state")
|
||||
setOrOwn("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
|
||||
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) {
|
||||
if _, ok := os.LookupEnv(k); !ok {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
200
legacy/go/session/exec.go
Normal file
200
legacy/go/session/exec.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// session — process execution and session spawning.
|
||||
//
|
||||
// Mirrors vendor/src/post_login/mod.rs and wait_with_log.rs.
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"latchd/config"
|
||||
)
|
||||
|
||||
type PostLoginEnv struct {
|
||||
Kind string // "x11", "wayland", "tty"
|
||||
Title string
|
||||
XinitrcPath string
|
||||
ScriptPath string
|
||||
DesktopNames string // for XDG_CURRENT_DESKTOP (colon-separated)
|
||||
SessionDesktop string // for XDG_SESSION_DESKTOP
|
||||
}
|
||||
|
||||
type SpawnedEnv struct {
|
||||
Kind string
|
||||
Server *exec.Cmd
|
||||
Client *exec.Cmd
|
||||
logFd *os.File
|
||||
}
|
||||
|
||||
const logLimit = 67_108_864
|
||||
|
||||
type limitWriter struct {
|
||||
w io.Writer
|
||||
n int64
|
||||
max int64
|
||||
}
|
||||
|
||||
func (l *limitWriter) Write(p []byte) (int, error) {
|
||||
if l.n >= l.max {
|
||||
return len(p), nil
|
||||
}
|
||||
rem := l.max - l.n
|
||||
if int64(len(p)) > rem {
|
||||
p = p[:rem]
|
||||
}
|
||||
n, err := l.w.Write(p)
|
||||
l.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func Spawn(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config) (*SpawnedEnv, error) {
|
||||
var loginFlag string
|
||||
switch cfg.ShellLoginFlag {
|
||||
case config.ShellLoginShort:
|
||||
loginFlag = "-l"
|
||||
case config.ShellLoginLong:
|
||||
loginFlag = "--login"
|
||||
}
|
||||
|
||||
switch env.Kind {
|
||||
case "x11":
|
||||
return spawnX11(env, uid, gid, groups, cfg, loginFlag)
|
||||
case "wayland":
|
||||
return spawnWayland(env, uid, gid, groups, cfg, loginFlag)
|
||||
default:
|
||||
return spawnTTY(env, uid, gid, groups, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func spawnX11(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config, loginFlag string) (*SpawnedEnv, error) {
|
||||
server, err := setupX(uid, gid, groups, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("X setup: %w", err)
|
||||
}
|
||||
|
||||
args := shellCmd(cfg.SystemShell, loginFlag, cfg.X11.SetupPath+" "+env.XinitrcPath)
|
||||
client := privCmd(args, uid, gid, groups)
|
||||
|
||||
logF := openLog(cfg.ClientLogPath)
|
||||
if logF != nil {
|
||||
lw := &limitWriter{w: logF, max: logLimit}
|
||||
client.Stdout = lw
|
||||
client.Stderr = lw
|
||||
}
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
return nil, fmt.Errorf("X11 client: %w", err)
|
||||
}
|
||||
return &SpawnedEnv{Kind: "x11", Server: server, Client: client, logFd: logF}, nil
|
||||
}
|
||||
|
||||
func spawnWayland(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config, loginFlag string) (*SpawnedEnv, error) {
|
||||
args := shellCmd(cfg.SystemShell, loginFlag, env.ScriptPath)
|
||||
client := privCmd(args, uid, gid, groups)
|
||||
|
||||
logF := openLog(cfg.ClientLogPath)
|
||||
if logF != nil {
|
||||
lw := &limitWriter{w: logF, max: logLimit}
|
||||
client.Stdout = lw
|
||||
client.Stderr = lw
|
||||
}
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
return nil, fmt.Errorf("Wayland: %w", err)
|
||||
}
|
||||
return &SpawnedEnv{Kind: "wayland", Client: client, logFd: logF}, nil
|
||||
}
|
||||
|
||||
func spawnTTY(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config) (*SpawnedEnv, error) {
|
||||
shell := userShell(int(uid))
|
||||
_ = env
|
||||
_ = cfg
|
||||
|
||||
client := privCmd([]string{shell}, uid, gid, groups)
|
||||
client.Stdin = os.Stdin
|
||||
client.Stdout = os.Stdout
|
||||
client.Stderr = os.Stderr
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
return nil, fmt.Errorf("TTY: %w", err)
|
||||
}
|
||||
return &SpawnedEnv{Kind: "tty", Client: client}, nil
|
||||
}
|
||||
|
||||
func (s *SpawnedEnv) Wait() error {
|
||||
defer func() {
|
||||
if s.logFd != nil {
|
||||
s.logFd.Close()
|
||||
}
|
||||
}()
|
||||
if s.Kind == "x11" && s.Server != nil {
|
||||
defer func() {
|
||||
s.Server.Process.Signal(syscall.SIGTERM)
|
||||
s.Server.Wait()
|
||||
}()
|
||||
}
|
||||
if s.Client != nil {
|
||||
return s.Client.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SpawnedEnv) PID() int {
|
||||
if s.Client != nil && s.Client.Process != nil {
|
||||
return s.Client.Process.Pid
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func shellCmd(shell, loginFlag, command string) []string {
|
||||
a := []string{shell}
|
||||
if loginFlag != "" {
|
||||
a = append(a, loginFlag)
|
||||
}
|
||||
return append(a, "-c", command)
|
||||
}
|
||||
|
||||
func privCmd(args []string, uid, gid uint32, groups []uint32) *exec.Cmd {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{
|
||||
Uid: uid,
|
||||
Gid: gid,
|
||||
Groups: groups,
|
||||
},
|
||||
Setsid: true,
|
||||
Setctty: false,
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func openLog(path string) *os.File {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func userShell(uid int) string {
|
||||
data, err := os.ReadFile("/etc/passwd")
|
||||
if err != nil {
|
||||
return "/bin/sh"
|
||||
}
|
||||
uidStr := strconv.Itoa(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"
|
||||
}
|
||||
149
legacy/go/session/scan.go
Normal file
149
legacy/go/session/scan.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// session — environment scanning from xsessions, wayland-sessions,
|
||||
// and custom script directories. Includes desktop entry parser.
|
||||
//
|
||||
// Mirrors vendor/src/post_login/mod.rs:get_envs and parse_desktop_entry.
|
||||
package session
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DesktopEntry struct {
|
||||
Name string
|
||||
Exec string
|
||||
DesktopNames string
|
||||
Hidden bool
|
||||
NoDisplay bool
|
||||
}
|
||||
|
||||
func parseDesktopEntry(path string) (*DesktopEntry, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
e := &DesktopEntry{}
|
||||
inSection := false
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
if line[0] == '[' && line[len(line)-1] == ']' {
|
||||
inSection = line == "[Desktop Entry]"
|
||||
continue
|
||||
}
|
||||
if !inSection {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
k, v := line[:eq], line[eq+1:]
|
||||
switch k {
|
||||
case "Name":
|
||||
e.Name = v
|
||||
case "Exec":
|
||||
e.Exec = v
|
||||
case "DesktopNames":
|
||||
e.DesktopNames = v
|
||||
case "Hidden":
|
||||
e.Hidden = strings.EqualFold(v, "true")
|
||||
case "NoDisplay":
|
||||
e.NoDisplay = strings.EqualFold(v, "true")
|
||||
}
|
||||
}
|
||||
if sc.Err() != nil {
|
||||
return nil, sc.Err()
|
||||
}
|
||||
if e.Exec == "" {
|
||||
return nil, fmt.Errorf("no Exec in %s", path)
|
||||
}
|
||||
if e.Name == "" {
|
||||
e.Name = e.Exec
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func ScanEnvs(xSessions, wlSessions, xScripts, wlScripts string, includeTTY bool) []PostLoginEnv {
|
||||
var envs []PostLoginEnv
|
||||
|
||||
scanDesktopDir(xSessions, "x11", &envs)
|
||||
scanDesktopDir(wlSessions, "wayland", &envs)
|
||||
scanScriptDir(xScripts, "x11", &envs)
|
||||
scanScriptDir(wlScripts, "wayland", &envs)
|
||||
|
||||
if len(envs) == 0 || includeTTY {
|
||||
envs = append(envs, PostLoginEnv{Kind: "tty", Title: "TTYSHELL"})
|
||||
}
|
||||
return envs
|
||||
}
|
||||
|
||||
func scanDesktopDir(dir, kind string, envs *[]PostLoginEnv) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".desktop" {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
de, err := parseDesktopEntry(path)
|
||||
if err != nil || de.Hidden || de.NoDisplay {
|
||||
continue
|
||||
}
|
||||
desktopNames, sessionDesktop := splitDesktopNames(de.DesktopNames, e.Name())
|
||||
*envs = append(*envs, PostLoginEnv{
|
||||
Kind: kind,
|
||||
Title: de.Name,
|
||||
XinitrcPath: 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) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.Mode()&0111 == 0 {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
*envs = append(*envs, PostLoginEnv{
|
||||
Kind: kind,
|
||||
Title: e.Name(),
|
||||
XinitrcPath: path,
|
||||
ScriptPath: path,
|
||||
})
|
||||
}
|
||||
}
|
||||
4
legacy/go/session/wayland.go
Normal file
4
legacy/go/session/wayland.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package session
|
||||
|
||||
// Wayland session spawning is handled in exec.go/spawnWayland.
|
||||
// This file exists for future Wayland-specific compositor logic.
|
||||
103
legacy/go/session/x11.go
Normal file
103
legacy/go/session/x11.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// session — X11 server setup.
|
||||
//
|
||||
// Mirrors vendor/src/post_login/x.rs:setup_x.
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"latchd/config"
|
||||
)
|
||||
|
||||
func setupX(uid, gid uint32, groups []uint32, cfg *config.Config) (*exec.Cmd, error) {
|
||||
display := os.Getenv("DISPLAY")
|
||||
vtnr := os.Getenv("XDG_VTNR")
|
||||
home := os.Getenv("HOME")
|
||||
if display == "" || vtnr == "" || home == "" {
|
||||
return nil, fmt.Errorf("missing DISPLAY/XDG_VTNR/HOME env vars")
|
||||
}
|
||||
|
||||
xauthPath := filepath.Join(home, ".Xauthority")
|
||||
os.Remove(xauthPath)
|
||||
|
||||
cookie, err := mCookie()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xauthCmd := exec.Command(cfg.X11.XauthPath, "add", display, ".", cookie)
|
||||
xauthCmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{Uid: uid, Gid: gid, Groups: groups},
|
||||
}
|
||||
if out, err := xauthCmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("xauth: %w — %s", err, string(out))
|
||||
}
|
||||
os.Setenv("XAUTHORITY", xauthPath)
|
||||
|
||||
dd := vtnr
|
||||
if len(dd) == 1 {
|
||||
dd = "0" + dd
|
||||
}
|
||||
|
||||
// Ignore SIGUSR1 so Xorg sends it when ready.
|
||||
signal.Ignore(syscall.SIGUSR1)
|
||||
|
||||
cmd := exec.Command(cfg.SystemShell, "-c",
|
||||
fmt.Sprintf("%s %s vt%s", cfg.X11.ServerPath, display, dd))
|
||||
if logF := openLog(cfg.X11.ServerLogPath); logF != nil {
|
||||
lw := &limitWriter{w: logF, max: logLimit}
|
||||
cmd.Stdout = lw
|
||||
cmd.Stderr = lw
|
||||
defer logF.Close()
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
signal.Reset(syscall.SIGUSR1)
|
||||
return nil, fmt.Errorf("X server: %w", err)
|
||||
}
|
||||
|
||||
signal.Reset(syscall.SIGUSR1)
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGUSR1)
|
||||
defer signal.Stop(ch)
|
||||
|
||||
timeout := time.Duration(cfg.X11.ServerTimeoutSecs) * time.Second
|
||||
deadline := time.After(timeout)
|
||||
tick := time.NewTicker(100 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
return cmd, nil
|
||||
case <-deadline:
|
||||
if cfg.X11.ServerTimeoutSecs == 0 {
|
||||
continue
|
||||
}
|
||||
cmd.Process.Signal(syscall.SIGTERM)
|
||||
cmd.Wait()
|
||||
return nil, fmt.Errorf("X server timed out after %ds", cfg.X11.ServerTimeoutSecs)
|
||||
case <-tick.C:
|
||||
var ws syscall.WaitStatus
|
||||
if pid, err := syscall.Wait4(cmd.Process.Pid, &ws, syscall.WNOHANG, nil); err == nil && pid == cmd.Process.Pid {
|
||||
return nil, fmt.Errorf("X server exited with status %d", ws.ExitStatus())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mCookie() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
129
legacy/go/tui/bg/automata.go
Normal file
129
legacy/go/tui/bg/automata.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// bg — cellular automata background for the TUI.
|
||||
//
|
||||
// Conway's Game of Life rendered at 30 Hz using Unicode half-block
|
||||
// characters (▀) to double the effective vertical resolution.
|
||||
// The grid is double-buffered for zero-allocation ticking, and
|
||||
// render output is built in a pre-allocated strings.Builder so the
|
||||
|
||||
package bg
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Bright orange (accent) for alive cells, dark grey for dead.
|
||||
const (
|
||||
fgAlive = "\x1b[38;2;255;127;0m"
|
||||
fgDead = "\x1b[38;2;30;30;30m"
|
||||
bgAlive = "\x1b[48;2;255;127;0m"
|
||||
bgDead = "\x1b[48;2;30;30;30m"
|
||||
reset = "\x1b[0m"
|
||||
block = "▀"
|
||||
)
|
||||
|
||||
type Automata struct {
|
||||
w, h int
|
||||
a, b []uint8
|
||||
useA bool
|
||||
buf strings.Builder
|
||||
}
|
||||
|
||||
func NewAutomata(w, h int) *Automata {
|
||||
a := &Automata{useA: true}
|
||||
a.Resize(w, h)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Automata) Resize(w, h int) {
|
||||
logH := h * 2
|
||||
if a.w == w && a.h == logH {
|
||||
return
|
||||
}
|
||||
a.w = w
|
||||
a.h = logH
|
||||
sz := w * logH
|
||||
a.a = make([]uint8, sz)
|
||||
a.b = make([]uint8, sz)
|
||||
for i := 0; i < sz; i++ {
|
||||
if rand.Float32() > 0.85 {
|
||||
a.a[i] = 1
|
||||
}
|
||||
}
|
||||
a.buf.Grow(w * h * 40)
|
||||
}
|
||||
|
||||
func (a *Automata) Tick() {
|
||||
src, dst := a.a, a.b
|
||||
if !a.useA {
|
||||
src, dst = a.b, a.a
|
||||
}
|
||||
w, h := a.w, a.h
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
i := y*w + x
|
||||
alive := src[i]
|
||||
n := a.count(src, x, y, w, h)
|
||||
if alive == 1 && (n == 2 || n == 3) {
|
||||
dst[i] = 1
|
||||
} else if alive == 0 && n == 3 {
|
||||
dst[i] = 1
|
||||
} else {
|
||||
dst[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
a.useA = !a.useA
|
||||
}
|
||||
|
||||
func (a *Automata) count(g []uint8, x, y, w, h int) uint8 {
|
||||
var c uint8
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
nx, ny := x+dx, y+dy
|
||||
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
||||
c += g[ny*w+nx]
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Frame returns the current generation as a single string suitable for
|
||||
// writing to the alternate screen.
|
||||
func (a *Automata) Frame() string {
|
||||
a.buf.Reset()
|
||||
src := a.a
|
||||
if !a.useA {
|
||||
src = a.b
|
||||
}
|
||||
w, h := a.w, a.h
|
||||
for y := 0; y < h; y += 2 {
|
||||
for x := range w {
|
||||
top := src[y*w+x]
|
||||
bot := uint8(0)
|
||||
if y+1 < h {
|
||||
bot = src[(y+1)*w+x]
|
||||
}
|
||||
if top == 1 {
|
||||
a.buf.WriteString(fgAlive)
|
||||
} else {
|
||||
a.buf.WriteString(fgDead)
|
||||
}
|
||||
if bot == 1 {
|
||||
a.buf.WriteString(bgAlive)
|
||||
} else {
|
||||
a.buf.WriteString(bgDead)
|
||||
}
|
||||
a.buf.WriteString(block)
|
||||
}
|
||||
a.buf.WriteString(reset)
|
||||
if y < h-2 {
|
||||
a.buf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return a.buf.String()
|
||||
}
|
||||
Reference in New Issue
Block a user