(Feat): Initial Commit

This commit is contained in:
2026-07-18 18:16:06 +05:30
commit f6085c342b
22 changed files with 3124 additions and 0 deletions

16
.github/workflows/check.yaml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: Integration Check CI
on:
push:
branches:
- main
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix -L flake check

296
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,296 @@
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
#
# Copyright 2022-2024, axodotdev
# SPDX-License-Identifier: MIT or Apache-2.0
#
# CI that:
#
# * checks for a Git Tag that looks like a release
# * builds artifacts with dist (archives, installers, hashes)
# * uploads those artifacts to temporary workflow zip
# * on success, uploads the artifacts to a GitHub Release
#
# Note that the GitHub Release will be created with a generated
# title/body based on your changelogs.
name: Release
permissions:
"contents": "write"
# This task will run whenever you push a git tag that looks like a version
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
#
# If PACKAGE_NAME is specified, then the announcement will be for that
# package (erroring out if it doesn't have the given version or isn't dist-able).
#
# If PACKAGE_NAME isn't specified, then the announcement will be for all
# (dist-able) packages in the workspace with that version (this mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent announcement for each one. However, GitHub
# will hard limit this to 3 tags per commit, as it will assume more tags is a
# mistake.
#
# If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease.
on:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install dist
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
# sure would be cool if github gave us proper conditionals...
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
# functionality based on whether this is a pull_request, and whether it's from a fork.
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
# but also really annoying to build CI around when it needs secrets to work right.)
- id: plan
run: |
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v4
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
# Build and packages all the platform-specific things
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
# Let the initial task tell us to not run (currently very blunt)
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
strategy:
fail-fast: false
# Target platforms/runners are computed by dist in create-release.
# Each member of the matrix has the following arguments:
#
# - runner: the github runner
# - dist-args: cli flags to pass to dist
# - install-dist: expression to run to install dist on the runner
#
# Typically there will be:
# - 1 "global" task that builds universal installers
# - N "local" tasks that build each platform's binaries and platform-specific installers
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
if ! command -v cargo > /dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Install dist
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
# We force bash here just because github makes it really hard to get values up
# to "real" actions without writing to env-vars, and writing to env-vars has
# inconsistent syntax between shell and powershell.
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v4
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Build and package all the platform-agnostic(ish) things
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v4
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v4
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
- name: Create GitHub Release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
run: |
# Write and read notes from a file to avoid quoting breaking things
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
announce:
needs:
- plan
- host
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive

54
.github/workflows/rust.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
on:
pull_request:
push:
branches:
- main
name: Continuous integration
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- uses: actions-rs/cargo@v1
with:
command: check
fmt:
name: Rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add rustfmt
- uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add clippy
- uses: actions-rs/cargo@v1
with:
command: clippy
args: -- -D warnings

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/target
go_project_build_documentation.md
documentation.md
session.md
build/*

56
.vscode/sessions.json vendored Normal file
View File

@@ -0,0 +1,56 @@
{
"$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v11/terminal-keeper.json",
"theme": "tribe",
"active": "default",
"activateOnStartup": true,
"keepExistingTerminals": false,
"sessions": {
"default": [
{
"name": "hello",
"autoExecuteCommands": true,
"icon": "person",
"color": "terminal.ansiGreen",
"commands": [
"echo hello"
]
},
[
{
"name": "docker:ros",
"commands": [
""
]
},
{
"name": "docker:k8s",
"commands": [
""
]
}
],
[
{
"name": "docker:nats",
"commands": [
""
]
},
{
"name": "docker:fleet",
"commands": [
""
]
}
]
],
"saved-session": [
{
"name": "connect",
"commands": [
""
]
}
]
}
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"cSpell.words": [
"BSPWM"
],
"go.testExplorer.enable": false
}

107
Makefile Normal file
View File

@@ -0,0 +1,107 @@
BINARY := latchd
SRC_DIR := src
CMD_DIR := $(SRC_DIR)
BUILD_DIR := build
PREFIX ?= /usr
BINDIR := $(DESTDIR)$(PREFIX)/bin
CONFDIR := $(DESTDIR)/etc/$(BINARY)
SYSTEMD := $(DESTDIR)/usr/lib/systemd/system
VERSION ?= 0.1.0
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILDDATE := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS := -s -w \
-X main.Version=$(VERSION) \
-X main.Commit=$(COMMIT) \
-X main.BuildTime=$(BUILDDATE)
GO := go
GOFLAGS := -trimpath -ldflags="$(LDFLAGS)"
## ansi helpers
BOLD := \033[1m
DIM := \033[2m
RED := \033[31m
GREEN := \033[32m
YELLOW := \033[33m
BLUE := \033[34m
CYAN := \033[36m
WHITE := \033[37m
RST := \033[0m
## ─── targets ────────────────────────────────────────────────
.PHONY: all build install uninstall clean test lint fmt vet \
run-preview release help
all: build ## build the release binary
build: $(BUILD_DIR)/$(BINARY) ## compile latchd
$(BUILD_DIR)/$(BINARY): $(shell find $(SRC_DIR) -name '*.go')
@printf " $(CYAN)%-10s$(RST) %s\n" "GO" "$@"
@mkdir -p $(BUILD_DIR)
@cd $(SRC_DIR) && $(GO) build $(GOFLAGS) -o ../$@ .
install: build ## install to $(PREFIX)
@printf " $(GREEN)%-10s$(RST) %s\n" "INSTALL" "$(BINDIR)/$(BINARY)"
@install -Dm755 $(BUILD_DIR)/$(BINARY) $(BINDIR)/$(BINARY)
@install -Dm644 extra/config.toml $(CONFDIR)/config.toml
@install -Dm755 extra/xsetup.sh $(CONFDIR)/xsetup.sh
@install -Dm644 extra/lemurs.pam $(DESTDIR)/etc/pam.d/$(BINARY)
@install -Dm644 extra/lemurs.service $(SYSTEMD)/$(BINARY).service
@mkdir -p $(CONFDIR)/wms $(CONFDIR)/wayland
@printf " $(GREEN)%-10s$(RST) done — run 'systemctl enable $(BINARY)' to activate\n" "INSTALL"
uninstall: ## remove installed files
@printf " $(RED)%-10s$(RST) %s\n" "RM" "$(BINDIR)/$(BINARY)"
@rm -f $(BINDIR)/$(BINARY)
@rm -rf $(CONFDIR)
@rm -f $(DESTDIR)/etc/pam.d/$(BINARY)
@rm -f $(SYSTEMD)/$(BINARY).service
clean: ## remove build artifacts
@printf " $(RED)%-10s$(RST) %s\n" "RM" "$(BUILD_DIR)"
@rm -rf $(BUILD_DIR)
test: ## run tests
@printf " $(YELLOW)%-10s$(RST) running tests...\n" "TEST"
@cd $(SRC_DIR) && $(GO) test ./...
lint: ## run linter
@printf " $(YELLOW)%-10s$(RST) linting...\n" "LINT"
@golangci-lint run $(SRC_DIR)/... 2>/dev/null || printf " $(DIM)%-10s$(RST) golangci-lint not found; skipping\n" ""
fmt: ## format source
@printf " $(BLUE)%-10s$(RST) formatting...\n" "FMT"
@cd $(SRC_DIR) && $(GO) fmt ./...
vet: ## run go vet
@printf " $(YELLOW)%-10s$(RST) vetting...\n" "VET"
@cd $(SRC_DIR) && $(GO) vet ./...
run-preview: build ## launch preview mode
@printf " $(GREEN)%-10s$(RST) starting preview...\n" "RUN"
@./$(BUILD_DIR)/$(BINARY) --preview --no-log
release: clean ## build with version info
@printf " $(BOLD)$(CYAN)╔══════════════════════════════════════╗$(RST)\n"
@printf " $(BOLD)$(CYAN)║ latchd $(VERSION) — release build ║$(RST)\n"
@printf " $(BOLD)$(CYAN)╚══════════════════════════════════════╝$(RST)\n"
@printf "\n"
@printf " $(DIM)commit$(RST) $(COMMIT)\n"
@printf " $(DIM)built$(RST) $(BUILDDATE)\n"
@printf "\n"
@$(MAKE) build
help: ## print this help
@printf " $(BOLD)latchd$(RST) — tui display manager\n"
@printf " $(DIM)version $(VERSION) commit $(COMMIT)$(RST)\n"
@printf "\n"
@printf " $(WHITE)targets:$(RST)\n"
@awk -F ':|##' '/^[a-zA-Z].*:.*##/ { \
printf " $(CYAN)%-16s$(RST) %s\n", $$1, $$NF }' $(MAKEFILE_LIST) \
| sort
@printf "\n"

197
README.md Normal file
View File

@@ -0,0 +1,197 @@
# latchd
a display manager that runs in your terminal. lets you pick a desktop environment and log in — supports tty, x11, and wayland.
## what problem does this solve
Most display managers are graphical. that means you need x11 or wayland running before you can even log in. latchd runs in a plain linux terminal (like tty2), so you can log in first, then start your desktop. it's lightweight, looks decent, and doesn't drag in a full graphics stack just to show a login screen.
## how it works
latchd takes over a virtual terminal (tty2 by default) and draws a login screen using ansi escape codes, powered by [bubbletea](https://github.com/charmbracelet/bubbletea). you type your username and password, pick a session from the switcher, and hit enter.
The actual authentication happens through pam (pluggable authentication modules). Once you're authenticated, latchd forks a child process that opens the pam session, sets up your environment, spawns your desktop or window manager, and does utmp accounting so tools like `who` and `loginctl` can see you're logged in.
For x11 sessions it handles the whole setup — xauthority cookies, sigusr1 handshake with the x server, timing out if xorg fails to start. for wayland it just runs your compositor script. for tty it drops you into your shell.
While the login screen is up, it renders a conway's game of life simulation as the background at 30fps, using unicode half-block characters for a pixelated look.
## what you get
- Session switcher with left/right arrows and dot indicators
- username and password fields
- Status line for errors and info
- power controls (f1 shutdown, f2 reboot by default — configurable)
- caps lock indicator
- shake animation on wrong password
- spring animation on switcher transitions
- faillock integration — shows remaining attempts, detects lockouts
- Admin override (ctrl+u) to unlock a locked-out account
- configurable color theming via toml
- variable substitution in config (`$varname`)
- Preview mode for testing inside an existing session
## Known Issues
This project is a work in progress. here's what you should know:
- **no tests**. at all. no unit tests, no integration tests. if you hit a bug, you'll find it the hard way.
- **Session spawning is a bit rough**. it uses `ForkExec` to re-launch itself as a child process, which means the pam session lifecycle isn't as clean as the original fork-then-exec model. this can cause pam modules that track session leaders to get confused.
- **a handful of open bugs** — edge cases in the switcher, occasional terminal state corruption on abrupt exits, and the automata background can flicker when the terminal resizes rapidly.
- **not all config options are fully wired**. things like `SwitcherConfig.ShowNeighbours`, various color options, and input field styling fields exist in the config struct but may not all be hooked up to the rendering yet.
## installing
### from source
you need go 1.22 or later and `libpam-dev` (or your distro's equivalent).
```bash
# clone and build
cd src
go build -ldflags="-s -w" -o latchd .
sudo cp latchd /usr/bin/
# set up config and session directories
sudo mkdir -p /etc/latchd/wms /etc/latchd/wayland /var/log /var/cache
sudo cp ../extra/config.toml /etc/latchd/config.toml
sudo cp ../extra/xsetup.sh /etc/latchd/xsetup.sh
sudo cp ../extra/lemurs.pam /etc/pam.d/latchd
sudo cp ../extra/lemurs.service /etc/systemd/system/latchd.service
# enable the service
sudo systemctl enable latchd
```
you can also use the makefile:
```bash
make build # compile to build/latchd
sudo make install # install to /usr/bin and set up systemd
make run-preview # test the login screen in your current terminal
```
### arch
```bash
pacman -S latchd
systemctl enable latchd
```
## command line
```
latchd [options]
-c, --config <file> config path (default: /etc/latchd/config.toml)
-v, --variables <file> variables path
--tty <n> override the tty number to run on
--preview run inside an existing session for testing
--no-log disable all logging
--show-config print the parsed config and exit
-V, --version print version and exit
--xsessions <dir> override xsessions path
--wlsessions <dir> override wayland-sessions path
--initial-path <path> override initial PATH value
```
## adding sessions
drop executable scripts into the right folder and they show up in the switcher.
**x11** — put your xinitrc scripts in `/etc/latchd/wms/`:
```bash
# /etc/latchd/wms/bspwm
#!/bin/sh
sxhkd &
exec bspwm
```
```bash
chmod +x /etc/latchd/wms/bspwm
```
**wayland** — same thing in `/etc/latchd/wayland/`:
```bash
# /etc/latchd/wayland/sway
#!/bin/sh
exec sway
```
latchd also reads freedesktop `.desktop` files from `/usr/share/xsessions` and `/usr/share/wayland-sessions` automatically.
tty sessions are added automatically if no other environments are found, or you can force one with `include_tty_shell = true` in the config.
## configuring
the config lives at `/etc/latchd/config.toml`. all options are documented inline in the file. you can also use a `variables.toml` file with `$VAR` syntax:
```toml
# /etc/latchd/variables.toml
accent = "orange"
```
```toml
# /etc/latchd/config.toml
[password_field.style]
title_color_focused = "$accent"
border_color_focused = "$accent"
```
## debugging
three log files:
- `/var/log/latchd.log` — main latchd events
- `/var/log/latchd.client.log` — stdout/stderr of your session
- `/var/log/latchd.xorg.log` — x server output (64mb cap)
run `latchd --show-config` to verify your configuration. run `latchd --preview` to test the login screen without leaving your current session.
## keybindings
standard shell-style bindings in text fields:
| key | what it does |
|---|---|
| ctrl+a | start of line |
| ctrl+e | end of line |
| ctrl+l or ctrl+u | clear field |
| ctrl+d | delete forward |
| ctrl+h | backspace |
| ctrl+b/f | left/right |
| ctrl+p/n | up/down (previous/next field) |
| tab, shift+tab | next/prev field |
| left/right | switch session (in switcher mode) |
| f1, f2 | power controls |
| ctrl+u | admin unlock (when account is locked) |
## source layout
```
src/
├── main.go entry point, cli, vt switching, session forking
├── auth/
│ ├── pam.go cgo pam bindings (locks goroutine to os thread)
│ ├── faillock.go faillock tally detection and reset
│ └── utmpx.go user accounting records
├── config/
│ └── config.go toml parsing, variable substitution, defaults
├── session/
│ ├── env.go xdg and environment variable setup
│ ├── exec.go process spawning, privilege dropping, log limits
│ ├── scan.go session discovery and .desktop parsing
│ ├── x11.go xorg setup, xauth, sigusr1 handshake
│ └── wayland.go wayland placeholder
├── login/
│ └── login.go login form ui, state machine, animations, rendering
└── tui/
└── bg/
└── automata.go conway's game of life background
```
## license
MIT or Apache-2.0

81
src/auth/faillock.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
}