(Feat): Initial Commit
This commit is contained in:
81
src/auth/faillock.go
Normal file
81
src/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
|
||||
}
|
||||
176
src/auth/pam.go
Normal file
176
src/auth/pam.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// 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>
|
||||
|
||||
// Match the //export-generated signature.
|
||||
extern int latchd_pam_conv(int, struct pam_message **,
|
||||
struct pam_response **, void *);
|
||||
|
||||
// Helper to create the conv struct with the proper cast.
|
||||
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"
|
||||
ouser "os/user"
|
||||
"runtime"
|
||||
"runtime/cgo"
|
||||
"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
|
||||
}
|
||||
|
||||
//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)(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
|
||||
|
||||
s := unsafe.Slice((**C.struct_pam_response)(unsafe.Pointer(&r)), int(numMsg))
|
||||
for i := 0; i < int(numMsg); i++ {
|
||||
c := C.CString(pw)
|
||||
if c == nil {
|
||||
return C.PAM_BUF_ERR
|
||||
}
|
||||
s[i] = &C.struct_pam_response{resp: c, 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)
|
||||
defer h.Delete()
|
||||
|
||||
conv := C.makePAMConv(unsafe.Pointer(&h))
|
||||
|
||||
var pamh *C.pam_handle_t
|
||||
ret := C.pam_start(cSvc, cUser, &conv, &pamh)
|
||||
if ret != C.PAM_SUCCESS {
|
||||
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)
|
||||
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)
|
||||
return nil, &AuthError{ErrAccountValidation, msg}
|
||||
}
|
||||
|
||||
u, err := ouser.Lookup(user)
|
||||
if err != nil {
|
||||
C.pam_end(pamh, ret)
|
||||
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: "/bin/sh",
|
||||
pamHandle: pamh,
|
||||
}, 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
76
src/auth/utmpx.go
Normal file
76
src/auth/utmpx.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// auth — UTMPX user accounting (glibc only).
|
||||
//
|
||||
// Mirrors vendor/src/auth/utmpx.rs.
|
||||
package auth
|
||||
|
||||
/*
|
||||
#include <utmpx.h>
|
||||
#include <string.h>
|
||||
#include <sys/time.h>
|
||||
#include <stdlib.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()
|
||||
ut.ut_tv.tv_sec = C.__uint32_t(us / 1_000_000)
|
||||
ut.ut_tv.tv_usec = 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
|
||||
}
|
||||
309
src/config/config.go
Normal file
309
src/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"
|
||||
`
|
||||
28
src/go.mod
Normal file
28
src/go.mod
Normal file
@@ -0,0 +1,28 @@
|
||||
module latchd
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/charmbracelet/bubbles v0.18.0
|
||||
github.com/charmbracelet/bubbletea v0.26.6
|
||||
github.com/charmbracelet/lipgloss v0.11.0
|
||||
github.com/mattn/go-runewidth v0.0.15
|
||||
)
|
||||
|
||||
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/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/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
|
||||
)
|
||||
40
src/go.sum
Normal file
40
src/go.sum
Normal file
@@ -0,0 +1,40 @@
|
||||
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/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
||||
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/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-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/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=
|
||||
785
src/login/login.go
Normal file
785
src/login/login.go
Normal file
@@ -0,0 +1,785 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"latchd/auth"
|
||||
"latchd/config"
|
||||
"latchd/session"
|
||||
"latchd/tui/bg"
|
||||
)
|
||||
|
||||
// ── pixelated user avatar ───────────────────────────────────────────────
|
||||
|
||||
var avatarArt = []string{
|
||||
` ▄▄▄▄▄ `,
|
||||
` ▄███████▄ `,
|
||||
` ██ ██ ██ `,
|
||||
` ██ ██ `,
|
||||
` ██ ███ ██ `,
|
||||
` █████████ `,
|
||||
` ██ ██ ██ `,
|
||||
` ██ ██ `,
|
||||
}
|
||||
|
||||
// ── messages ─────────────────────────────────────────────────────────────
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
type authResult struct {
|
||||
ok bool
|
||||
err error
|
||||
faillock auth.FaillockStatus
|
||||
}
|
||||
|
||||
type adminBypassResult struct {
|
||||
ok bool
|
||||
err error
|
||||
}
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
modeNormal mode = iota
|
||||
modeSwitcher
|
||||
modeUsername
|
||||
modePassword
|
||||
modeAdminUser
|
||||
modeAdminPass
|
||||
)
|
||||
|
||||
// ── result (sent back to main on successful login) ──────────────────────
|
||||
|
||||
type Result struct {
|
||||
Username string
|
||||
Password string
|
||||
Env session.PostLoginEnv
|
||||
Config config.Config
|
||||
Preview bool
|
||||
}
|
||||
|
||||
// ── model ───────────────────────────────────────────────────────────────
|
||||
|
||||
type Model struct {
|
||||
cfg config.Config
|
||||
preview bool
|
||||
width int
|
||||
height int
|
||||
|
||||
automata *bg.Automata
|
||||
|
||||
envs []session.PostLoginEnv
|
||||
selIdx int
|
||||
|
||||
username string
|
||||
password string
|
||||
ucur int
|
||||
pcur int
|
||||
uscroll int
|
||||
pscroll int
|
||||
|
||||
adminUser string
|
||||
adminPass string
|
||||
aucur int
|
||||
apcur int
|
||||
lockedFor string
|
||||
|
||||
mode mode
|
||||
status string
|
||||
statusErr bool
|
||||
shakeOff int
|
||||
shakeLeft float64
|
||||
shakeDec float64
|
||||
spring float64
|
||||
|
||||
quitting bool
|
||||
result *Result
|
||||
}
|
||||
|
||||
func New(cfg config.Config, preview bool) *Model {
|
||||
raw := session.ScanEnvs(
|
||||
cfg.X11.SessionsPath, cfg.Wayland.SessionsPath,
|
||||
cfg.X11.ScriptsPath, cfg.Wayland.ScriptsPath,
|
||||
cfg.Switcher.IncludeTTYShell,
|
||||
)
|
||||
m := &Model{cfg: cfg, preview: preview, envs: raw}
|
||||
switch cfg.FocusBehaviour {
|
||||
case config.FocusEnvironment:
|
||||
if len(raw) > 0 {
|
||||
m.mode = modeSwitcher
|
||||
} else {
|
||||
m.mode = modeUsername
|
||||
}
|
||||
case config.FocusUsername:
|
||||
m.mode = modeUsername
|
||||
case config.FocusPassword:
|
||||
m.mode = modePassword
|
||||
default:
|
||||
if len(raw) > 0 {
|
||||
m.mode = modeSwitcher
|
||||
} else {
|
||||
m.mode = modeUsername
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return tea.Batch(tick(), tea.EnterAltScreen)
|
||||
}
|
||||
|
||||
// ── update ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
if m.automata != nil {
|
||||
m.automata.Resize(msg.Width, msg.Height)
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
case tickMsg:
|
||||
if m.automata != nil {
|
||||
m.automata.Tick()
|
||||
}
|
||||
m.tickShake()
|
||||
m.tickSpring()
|
||||
return m, tick()
|
||||
case authResult:
|
||||
return m.handleAuth(msg)
|
||||
case adminBypassResult:
|
||||
return m.handleBypass(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) tickShake() {
|
||||
if m.shakeLeft > 0.01 || m.shakeLeft < -0.01 {
|
||||
m.shakeDec += 0.05
|
||||
m.shakeLeft *= 0.82
|
||||
m.shakeOff = int(m.shakeLeft * 6.0 * (1.0 - m.shakeDec))
|
||||
if m.shakeDec >= 1.0 {
|
||||
m.shakeOff, m.shakeLeft, m.shakeDec = 0, 0, 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) tickSpring() {
|
||||
if m.spring > 0.5 || m.spring < -0.5 {
|
||||
m.spring *= 0.75
|
||||
} else {
|
||||
m.spring = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleAuth(msg authResult) (tea.Model, tea.Cmd) {
|
||||
if msg.ok {
|
||||
m.result = &Result{
|
||||
Username: m.username, Password: m.password,
|
||||
Env: m.selectedEnv(), Config: m.cfg, Preview: m.preview,
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.status = "incorrect username or password"
|
||||
m.statusErr = true
|
||||
m.shakeLeft = 1.0
|
||||
m.shakeDec = 0
|
||||
m.password = ""
|
||||
m.pcur = 0
|
||||
m.pscroll = 0
|
||||
if msg.faillock.Enabled && msg.faillock.LockedOut {
|
||||
m.lockedFor = m.username
|
||||
m.status = fmt.Sprintf("account locked — %d/%d attempts [Ctrl+U to unlock]",
|
||||
msg.faillock.Failures, msg.faillock.MaxTries)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) handleBypass(msg adminBypassResult) (tea.Model, tea.Cmd) {
|
||||
if msg.ok {
|
||||
m.lockedFor = ""
|
||||
m.mode = modeNormal
|
||||
m.adminUser = ""
|
||||
m.adminPass = ""
|
||||
m.status = "account unlocked — try logging in again"
|
||||
m.statusErr = false
|
||||
} else {
|
||||
m.status = "override failed: " + msg.err.Error()
|
||||
m.statusErr = true
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := msg.String()
|
||||
|
||||
if m.mode == modeAdminUser || m.mode == modeAdminPass {
|
||||
return m.adminKey(msg)
|
||||
}
|
||||
|
||||
switch k {
|
||||
case "esc":
|
||||
if m.preview {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.mode, m.status = modeNormal, ""
|
||||
return m, nil
|
||||
case "ctrl+c":
|
||||
if m.preview {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+u":
|
||||
if m.lockedFor != "" {
|
||||
m.mode = modeAdminUser
|
||||
m.status = "admin unlock · enter username"
|
||||
m.statusErr = false
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
switch k {
|
||||
case "tab", "down", "ctrl+n":
|
||||
m.mode = m.nextMode()
|
||||
case "shift+tab", "up", "ctrl+p":
|
||||
m.mode = m.prevMode()
|
||||
}
|
||||
|
||||
if k == "enter" && m.mode == modePassword {
|
||||
return m, m.loginCmd()
|
||||
}
|
||||
|
||||
if len(k) >= 2 && k[0] == 'f' {
|
||||
m.handleFn(k)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.mode {
|
||||
case modeSwitcher:
|
||||
switch k {
|
||||
case "left", "h":
|
||||
if m.selIdx > 0 {
|
||||
m.selIdx--
|
||||
m.spring = -4
|
||||
}
|
||||
case "right", "l":
|
||||
if m.selIdx < len(m.envs)-1 {
|
||||
m.selIdx++
|
||||
m.spring = 4
|
||||
}
|
||||
}
|
||||
case modeUsername:
|
||||
handleField(k, &m.username, &m.ucur, &m.uscroll)
|
||||
case modePassword:
|
||||
handleField(k, &m.password, &m.pcur, &m.pscroll)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) adminKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := msg.String()
|
||||
switch k {
|
||||
case "esc":
|
||||
m.mode, m.adminUser, m.adminPass, m.status = modeNormal, "", "", ""
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.mode == modeAdminUser && m.adminUser != "" {
|
||||
m.mode = modeAdminPass
|
||||
m.status = "admin unlock · enter password"
|
||||
return m, nil
|
||||
}
|
||||
if m.mode == modeAdminPass && m.adminPass != "" {
|
||||
return m, m.adminBypassCmd()
|
||||
}
|
||||
case "tab":
|
||||
if m.mode == modeAdminUser {
|
||||
m.mode = modeAdminPass
|
||||
} else {
|
||||
m.mode = modeAdminUser
|
||||
}
|
||||
default:
|
||||
if m.mode == modeAdminUser {
|
||||
handleField(k, &m.adminUser, &m.aucur, &m.aucur)
|
||||
} else {
|
||||
handleField(k, &m.adminPass, &m.apcur, &m.apcur)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) nextMode() mode {
|
||||
switch m.mode {
|
||||
case modeNormal:
|
||||
if len(m.envs) > 0 {
|
||||
return modeSwitcher
|
||||
}
|
||||
return modeUsername
|
||||
case modeSwitcher:
|
||||
return modeUsername
|
||||
case modeUsername:
|
||||
return modePassword
|
||||
}
|
||||
return m.mode
|
||||
}
|
||||
|
||||
func (m *Model) prevMode() mode {
|
||||
switch m.mode {
|
||||
case modeSwitcher:
|
||||
return modeNormal
|
||||
case modeUsername:
|
||||
if len(m.envs) > 0 {
|
||||
return modeSwitcher
|
||||
}
|
||||
return modeNormal
|
||||
case modePassword:
|
||||
return modeUsername
|
||||
}
|
||||
return modeNormal
|
||||
}
|
||||
|
||||
func (m *Model) handleFn(k string) {
|
||||
for _, pc := range m.cfg.PowerControls.BaseEntries {
|
||||
if strings.EqualFold(k, pc.Key) {
|
||||
exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start()
|
||||
}
|
||||
}
|
||||
for _, pc := range m.cfg.PowerControls.Entries {
|
||||
if strings.EqualFold(k, pc.Key) {
|
||||
exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) selectedEnv() session.PostLoginEnv {
|
||||
if len(m.envs) == 0 || m.selIdx >= len(m.envs) {
|
||||
return session.PostLoginEnv{}
|
||||
}
|
||||
return m.envs[m.selIdx]
|
||||
}
|
||||
|
||||
// ── commands ────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Model) loginCmd() tea.Cmd {
|
||||
if m.preview {
|
||||
return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} }
|
||||
}
|
||||
user, pass, svc := m.username, m.password, m.cfg.PAMService
|
||||
return func() tea.Msg {
|
||||
_, err := auth.Validate(user, pass, svc)
|
||||
if err == nil {
|
||||
return authResult{ok: true}
|
||||
}
|
||||
fs, _ := auth.GetFaillockStatus(user)
|
||||
return authResult{ok: false, err: err, faillock: fs}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) adminBypassCmd() tea.Cmd {
|
||||
au, ap, tgt := m.adminUser, m.adminPass, m.lockedFor
|
||||
return func() tea.Msg {
|
||||
if _, err := auth.Validate(au, ap, "su"); err != nil {
|
||||
return adminBypassResult{ok: false, err: fmt.Errorf("admin auth failed")}
|
||||
}
|
||||
if err := auth.ResetFaillock(tgt); err != nil {
|
||||
return adminBypassResult{ok: false, err: fmt.Errorf("faillock: %w", err)}
|
||||
}
|
||||
return adminBypassResult{ok: true}
|
||||
}
|
||||
}
|
||||
|
||||
// ── queries ────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Model) Result() *Result { return m.result }
|
||||
func (m *Model) Quitting() bool { return m.quitting }
|
||||
func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a }
|
||||
|
||||
// ── view ───────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
accent = lipgloss.Color("208") // orange
|
||||
subtle = lipgloss.Color("8") // grey
|
||||
bright = lipgloss.Color("15") // white
|
||||
errCol = lipgloss.Color("1") // red
|
||||
infoCol = lipgloss.Color("3") // yellow
|
||||
greenCol = lipgloss.Color("2") // green
|
||||
|
||||
avatarStyle = lipgloss.NewStyle().Foreground(accent)
|
||||
|
||||
fieldBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(subtle).
|
||||
Padding(0, 1).
|
||||
Width(34)
|
||||
|
||||
fieldBoxFocused = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(accent).
|
||||
Padding(0, 1).
|
||||
Width(34)
|
||||
|
||||
fieldLabel = lipgloss.NewStyle().Foreground(subtle).Padding(0, 0)
|
||||
fieldText = lipgloss.NewStyle().Foreground(bright)
|
||||
fieldFocus = lipgloss.NewStyle().Foreground(accent)
|
||||
|
||||
swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true)
|
||||
swNeighbour = lipgloss.NewStyle().Foreground(subtle)
|
||||
swMover = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||
swDot = lipgloss.NewStyle().Foreground(subtle)
|
||||
swDotActive = lipgloss.NewStyle().Foreground(accent)
|
||||
|
||||
powerHint = lipgloss.NewStyle().Foreground(subtle).Faint(true)
|
||||
|
||||
capsOnStyle = lipgloss.NewStyle().Foreground(greenCol).Bold(true)
|
||||
capsOffStyle = lipgloss.NewStyle().Foreground(subtle).Faint(true)
|
||||
|
||||
statusErrStyle = lipgloss.NewStyle().Foreground(errCol)
|
||||
statusInfoStyle = lipgloss.NewStyle().Foreground(infoCol)
|
||||
|
||||
adminLabel = lipgloss.NewStyle().Foreground(infoCol).Bold(true)
|
||||
)
|
||||
|
||||
func (m *Model) View() string {
|
||||
if m.quitting || m.result != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := m.renderLayout()
|
||||
|
||||
if m.automata != nil && m.width > 0 && m.height > 0 {
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
||||
content,
|
||||
lipgloss.WithWhitespaceChars(" "),
|
||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
|
||||
)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (m *Model) renderLayout() string {
|
||||
// top bar: power hints
|
||||
top := m.renderPowerHints()
|
||||
top = lipgloss.NewStyle().Width(80).Align(lipgloss.Left).Render(top)
|
||||
|
||||
// avatar + form side by side
|
||||
avatar := m.renderAvatar()
|
||||
form := m.renderForm()
|
||||
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
|
||||
|
||||
// DE switcher below
|
||||
sw := m.renderSwitcherDots()
|
||||
|
||||
// caps lock
|
||||
caps := m.renderCapsLock()
|
||||
|
||||
// admin unlock
|
||||
var admin string
|
||||
if m.mode == modeAdminUser || m.mode == modeAdminPass {
|
||||
admin = m.renderAdminForm()
|
||||
}
|
||||
|
||||
// status
|
||||
st := m.renderStatus()
|
||||
|
||||
var parts []string
|
||||
parts = append(parts, top)
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, mid)
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, sw)
|
||||
if caps != "" {
|
||||
parts = append(parts, caps)
|
||||
}
|
||||
if admin != "" {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, admin)
|
||||
}
|
||||
if st != "" {
|
||||
parts = append(parts, st)
|
||||
}
|
||||
|
||||
joined := lipgloss.JoinVertical(lipgloss.Center, parts...)
|
||||
if m.shakeOff != 0 {
|
||||
pad := strings.Repeat(" ", abs(m.shakeOff))
|
||||
if m.shakeOff > 0 {
|
||||
joined = pad + joined
|
||||
}
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
func (m *Model) renderPowerHints() string {
|
||||
var hints []string
|
||||
for _, pc := range m.cfg.PowerControls.BaseEntries {
|
||||
hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint))
|
||||
}
|
||||
for _, pc := range m.cfg.PowerControls.Entries {
|
||||
hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint))
|
||||
}
|
||||
return strings.Join(hints, " ")
|
||||
}
|
||||
|
||||
func (m *Model) renderAvatar() string {
|
||||
var lines []string
|
||||
for _, row := range avatarArt {
|
||||
lines = append(lines, avatarStyle.Render(row))
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
|
||||
func (m *Model) renderForm() string {
|
||||
userLabel := fieldLabel.Render("username")
|
||||
if m.mode == modeUsername {
|
||||
userLabel = fieldFocus.Render("▸ username")
|
||||
}
|
||||
userVal := m.username
|
||||
if userVal == "" {
|
||||
userVal = " "
|
||||
}
|
||||
ubox := fieldBoxFocused
|
||||
utxt := fieldFocus
|
||||
if m.mode != modeUsername {
|
||||
ubox = fieldBox
|
||||
utxt = fieldText
|
||||
}
|
||||
userField := ubox.Render(userLabel + "\n" + utxt.Render(userVal))
|
||||
|
||||
passLabel := fieldLabel.Render("password")
|
||||
if m.mode == modePassword {
|
||||
passLabel = fieldFocus.Render("▸ password")
|
||||
}
|
||||
passVal := strings.Repeat("•", len(m.password))
|
||||
if passVal == "" {
|
||||
passVal = " "
|
||||
}
|
||||
pbox := fieldBoxFocused
|
||||
ptxt := fieldFocus
|
||||
if m.mode != modePassword {
|
||||
pbox = fieldBox
|
||||
ptxt = fieldText
|
||||
}
|
||||
passField := pbox.Render(passLabel + "\n" + ptxt.Render(passVal))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, userField, "", passField)
|
||||
}
|
||||
|
||||
func (m *Model) renderSwitcherDots() string {
|
||||
if len(m.envs) == 0 {
|
||||
return swNeighbour.Align(lipgloss.Center).Render("no desktop environments found")
|
||||
}
|
||||
|
||||
cfg := m.cfg.Switcher
|
||||
w := int(cfg.MaxDisplayLength)
|
||||
|
||||
var parts []string
|
||||
|
||||
if cfg.ShowMovers && m.selIdx > 0 {
|
||||
parts = append(parts, swMover.Render(cfg.LeftMover+" "))
|
||||
}
|
||||
|
||||
if cfg.ShowNeighbours && m.selIdx > 0 {
|
||||
parts = append(parts, swNeighbour.Render(padTrim(m.envs[m.selIdx-1].Title, w)+" "))
|
||||
}
|
||||
|
||||
cur := swSelected.Render(padTrim(m.envs[m.selIdx].Title, w))
|
||||
parts = append(parts, cur)
|
||||
|
||||
if cfg.ShowNeighbours && m.selIdx < len(m.envs)-1 {
|
||||
parts = append(parts, swNeighbour.Render(" "+padTrim(m.envs[m.selIdx+1].Title, w)))
|
||||
}
|
||||
|
||||
if cfg.ShowMovers && m.selIdx < len(m.envs)-1 {
|
||||
parts = append(parts, swMover.Render(" "+cfg.RightMover))
|
||||
}
|
||||
|
||||
swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...)
|
||||
|
||||
// Dot indicators
|
||||
var dots []string
|
||||
for i := 0; i < len(m.envs); i++ {
|
||||
if i == m.selIdx {
|
||||
dots = append(dots, swDotActive.Render("●"))
|
||||
} else {
|
||||
dots = append(dots, swDot.Render("·"))
|
||||
}
|
||||
}
|
||||
dotLine := strings.Join(dots, " ")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Center, swLine, dotLine)
|
||||
}
|
||||
|
||||
func (m *Model) renderCapsLock() string {
|
||||
if capsLockOn() {
|
||||
return capsOnStyle.Align(lipgloss.Center).Render("⬤ Caps Lock")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Model) renderAdminForm() string {
|
||||
label := adminLabel.Render("── admin override ──")
|
||||
|
||||
auLabel := fieldLabel.Render("admin username")
|
||||
if m.mode == modeAdminUser {
|
||||
auLabel = fieldFocus.Render("▸ admin username")
|
||||
}
|
||||
auVal := m.adminUser
|
||||
if auVal == "" {
|
||||
auVal = " "
|
||||
}
|
||||
auBox := fieldBox
|
||||
if m.mode == modeAdminUser {
|
||||
auBox = fieldBoxFocused
|
||||
}
|
||||
auField := auBox.Render(auLabel + "\n" + fieldText.Render(auVal))
|
||||
|
||||
apLabel := fieldLabel.Render("admin password")
|
||||
if m.mode == modeAdminPass {
|
||||
apLabel = fieldFocus.Render("▸ admin password")
|
||||
}
|
||||
apVal := strings.Repeat("•", len(m.adminPass))
|
||||
if apVal == "" {
|
||||
apVal = " "
|
||||
}
|
||||
apBox := fieldBox
|
||||
if m.mode == modeAdminPass {
|
||||
apBox = fieldBoxFocused
|
||||
}
|
||||
apField := apBox.Render(apLabel + "\n" + fieldText.Render(apVal))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Center,
|
||||
label, "", auField, "", apField,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Model) renderStatus() string {
|
||||
if m.status == "" {
|
||||
return ""
|
||||
}
|
||||
st := statusInfoStyle
|
||||
if m.statusErr {
|
||||
st = statusErrStyle
|
||||
}
|
||||
return st.Align(lipgloss.Center).Width(70).Render(m.status)
|
||||
}
|
||||
|
||||
// ── caps lock detection ────────────────────────────────────────────────
|
||||
|
||||
const kdgkbled = 0x4B64
|
||||
|
||||
func capsLockOn() bool {
|
||||
fd := int(os.Stdin.Fd())
|
||||
var leds int32
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
|
||||
uintptr(fd), kdgkbled, uintptr(unsafe.Pointer(&leds)))
|
||||
if errno != 0 {
|
||||
return false
|
||||
}
|
||||
return leds&0x04 != 0
|
||||
}
|
||||
|
||||
// ── field editing ──────────────────────────────────────────────────────
|
||||
|
||||
func handleField(k string, buf *string, cur, scroll *int) {
|
||||
switch {
|
||||
case k == "backspace" || k == "ctrl+h":
|
||||
if *cur == 0 && *scroll == 0 {
|
||||
return
|
||||
}
|
||||
moveLeft(cur, scroll, *buf)
|
||||
r := []rune(*buf)
|
||||
idx := *scroll + *cur
|
||||
if idx < len(r) {
|
||||
*buf = string(append(r[:idx], r[idx+1:]...))
|
||||
}
|
||||
case k == "delete" || k == "ctrl+d":
|
||||
r := []rune(*buf)
|
||||
idx := *scroll + *cur
|
||||
if idx < len(r) {
|
||||
*buf = string(append(r[:idx], r[idx+1:]...))
|
||||
}
|
||||
case k == "ctrl+a":
|
||||
*cur, *scroll = 0, 0
|
||||
case k == "ctrl+e":
|
||||
*cur, *scroll = len([]rune(*buf)), 0
|
||||
case k == "ctrl+l", k == "ctrl+u":
|
||||
*buf, *cur, *scroll = "", 0, 0
|
||||
case k == "left" || k == "ctrl+b":
|
||||
moveLeft(cur, scroll, *buf)
|
||||
case k == "right" || k == "ctrl+f":
|
||||
moveRight(cur, scroll, *buf)
|
||||
default:
|
||||
if len(k) == 1 {
|
||||
r := []rune(*buf)
|
||||
idx := *scroll + *cur
|
||||
if idx > len(r) {
|
||||
idx = len(r)
|
||||
}
|
||||
ch := []rune(k)[0]
|
||||
r = append(r[:idx], append([]rune{ch}, r[idx:]...)...)
|
||||
*buf = string(r)
|
||||
if *cur >= 30 {
|
||||
*scroll++
|
||||
} else {
|
||||
*cur++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveLeft(cur, scroll *int, buf string) {
|
||||
if *cur > 0 {
|
||||
*cur--
|
||||
return
|
||||
}
|
||||
if *scroll > 0 {
|
||||
*scroll--
|
||||
}
|
||||
}
|
||||
|
||||
func moveRight(cur, scroll *int, buf string) {
|
||||
if *scroll+*cur >= len([]rune(buf)) {
|
||||
return
|
||||
}
|
||||
if *cur >= 30 {
|
||||
*scroll++
|
||||
} else {
|
||||
*cur++
|
||||
}
|
||||
}
|
||||
|
||||
func padTrim(s string, w int) string {
|
||||
if len(s) > w {
|
||||
return s[:w]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ── tick ────────────────────────────────────────────────────────────────
|
||||
|
||||
func tick() tea.Cmd {
|
||||
return tea.Tick(time.Second/30, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
260
src/main.go
Normal file
260
src/main.go
Normal file
@@ -0,0 +1,260 @@
|
||||
// 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/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()
|
||||
|
||||
pid, err := syscall.ForkExec("/proc/self/exe", []string{
|
||||
"latchd-session",
|
||||
"--user", creds.Username,
|
||||
"--uid", fmt.Sprintf("%d", creds.UID),
|
||||
"--gid", fmt.Sprintf("%d", creds.PrimaryGID),
|
||||
"--home", creds.HomeDir,
|
||||
"--shell", creds.Shell,
|
||||
"--env-kind", res.Env.Kind,
|
||||
"--env-exec", res.Env.XinitrcPath,
|
||||
"--tty", fmt.Sprintf("%d", res.Config.TTY),
|
||||
}, &syscall.ProcAttr{
|
||||
Env: os.Environ(),
|
||||
Dir: creds.HomeDir,
|
||||
Files: []uintptr{0, 1, 2},
|
||||
Sys: &syscall.SysProcAttr{Setsid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("fork: %w", err)
|
||||
}
|
||||
|
||||
creds.OpenSession()
|
||||
|
||||
var ws syscall.WaitStatus
|
||||
if _, err := syscall.Wait4(pid, &ws, 0, nil); err != nil {
|
||||
return fmt.Errorf("wait: %w", err)
|
||||
}
|
||||
log.Printf("session pid %d exited %d", pid, ws.ExitStatus())
|
||||
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)
|
||||
}()
|
||||
}
|
||||
64
src/session/env.go
Normal file
64
src/session/env.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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 setOrOwn(k, v string) {
|
||||
if _, ok := os.LookupEnv(k); !ok {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
198
src/session/exec.go
Normal file
198
src/session/exec.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
133
src/session/scan.go
Normal file
133
src/session/scan.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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
|
||||
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 "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
|
||||
}
|
||||
*envs = append(*envs, PostLoginEnv{
|
||||
Kind: kind,
|
||||
Title: de.Name,
|
||||
XinitrcPath: de.Exec,
|
||||
ScriptPath: de.Exec,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
src/session/wayland.go
Normal file
4
src/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
src/session/x11.go
Normal file
103
src/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
src/tui/bg/automata.go
Normal file
129
src/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