commit f6085c342b8cb8794293278457035de622e81329 Author: Zane Walker Date: Sat Jul 18 18:16:06 2026 +0530 (Feat): Initial Commit diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..4765913 --- /dev/null +++ b/.github/workflows/check.yaml @@ -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 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3c59af5 --- /dev/null +++ b/.github/workflows/release.yml @@ -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<> "$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<> "$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 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..167bfb0 --- /dev/null +++ b/.github/workflows/rust.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bac2414 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +go_project_build_documentation.md +documentation.md + +session.md +build/* \ No newline at end of file diff --git a/.vscode/sessions.json b/.vscode/sessions.json new file mode 100644 index 0000000..06f7365 --- /dev/null +++ b/.vscode/sessions.json @@ -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": [ + "" + ] + } + ] + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ee996a7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cSpell.words": [ + "BSPWM" + ], + "go.testExplorer.enable": false +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..76736dd --- /dev/null +++ b/Makefile @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f242987 --- /dev/null +++ b/README.md @@ -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 config path (default: /etc/latchd/config.toml) + -v, --variables variables path + --tty 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 override xsessions path + --wlsessions override wayland-sessions path + --initial-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 diff --git a/src/auth/faillock.go b/src/auth/faillock.go new file mode 100644 index 0000000..798a729 --- /dev/null +++ b/src/auth/faillock.go @@ -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 +} diff --git a/src/auth/pam.go b/src/auth/pam.go new file mode 100644 index 0000000..649ce16 --- /dev/null +++ b/src/auth/pam.go @@ -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 +#include + +// 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 +} diff --git a/src/auth/utmpx.go b/src/auth/utmpx.go new file mode 100644 index 0000000..5e5637a --- /dev/null +++ b/src/auth/utmpx.go @@ -0,0 +1,76 @@ +// auth — UTMPX user accounting (glibc only). +// +// Mirrors vendor/src/auth/utmpx.rs. +package auth + +/* +#include +#include +#include +#include +*/ +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 +} diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..7e4216a --- /dev/null +++ b/src/config/config.go @@ -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" +` diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..cad9f2e --- /dev/null +++ b/src/go.mod @@ -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 +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..a771b94 --- /dev/null +++ b/src/go.sum @@ -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= diff --git a/src/login/login.go b/src/login/login.go new file mode 100644 index 0000000..f9e06ec --- /dev/null +++ b/src/login/login.go @@ -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) + }) +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..4b83d9c --- /dev/null +++ b/src/main.go @@ -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) + }() +} diff --git a/src/session/env.go b/src/session/env.go new file mode 100644 index 0000000..b3a7a0b --- /dev/null +++ b/src/session/env.go @@ -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) + } +} diff --git a/src/session/exec.go b/src/session/exec.go new file mode 100644 index 0000000..204b2ba --- /dev/null +++ b/src/session/exec.go @@ -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" +} diff --git a/src/session/scan.go b/src/session/scan.go new file mode 100644 index 0000000..7c207e2 --- /dev/null +++ b/src/session/scan.go @@ -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, + }) + } +} diff --git a/src/session/wayland.go b/src/session/wayland.go new file mode 100644 index 0000000..97c832c --- /dev/null +++ b/src/session/wayland.go @@ -0,0 +1,4 @@ +package session + +// Wayland session spawning is handled in exec.go/spawnWayland. +// This file exists for future Wayland-specific compositor logic. diff --git a/src/session/x11.go b/src/session/x11.go new file mode 100644 index 0000000..5090c13 --- /dev/null +++ b/src/session/x11.go @@ -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 +} diff --git a/src/tui/bg/automata.go b/src/tui/bg/automata.go new file mode 100644 index 0000000..9cf7a05 --- /dev/null +++ b/src/tui/bg/automata.go @@ -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() +}