(Feat): Initial Commit
This commit is contained in:
16
.github/workflows/check.yaml
vendored
Normal file
16
.github/workflows/check.yaml
vendored
Normal 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
296
.github/workflows/release.yml
vendored
Normal 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
54
.github/workflows/rust.yml
vendored
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
go_project_build_documentation.md
|
||||
documentation.md
|
||||
|
||||
session.md
|
||||
build/*
|
||||
56
.vscode/sessions.json
vendored
Normal file
56
.vscode/sessions.json
vendored
Normal 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
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"BSPWM"
|
||||
],
|
||||
"go.testExplorer.enable": false
|
||||
}
|
||||
107
Makefile
Normal file
107
Makefile
Normal 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
197
README.md
Normal 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
81
src/auth/faillock.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// auth — faillock integration.
|
||||
//
|
||||
// Reads /etc/security/faillock.conf for deny count, executes
|
||||
// faillock --user to get tally, supports --reset via admin bypass.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FaillockStatus struct {
|
||||
Enabled bool
|
||||
Failures int
|
||||
MaxTries int
|
||||
Remaining int
|
||||
LockedOut bool
|
||||
}
|
||||
|
||||
func GetFaillockStatus(user string) (FaillockStatus, error) {
|
||||
s := FaillockStatus{}
|
||||
|
||||
data, err := os.ReadFile("/etc/security/faillock.conf")
|
||||
if err != nil {
|
||||
return s, nil
|
||||
}
|
||||
s.Enabled = true
|
||||
s.MaxTries = 3
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "deny") {
|
||||
parts := strings.Split(line, "=")
|
||||
if len(parts) == 2 {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
|
||||
s.MaxTries = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command("faillock", "--user", user)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return s, fmt.Errorf("faillock: %w", err)
|
||||
}
|
||||
|
||||
s.Failures = countFaillockLines(out.String())
|
||||
s.Remaining = s.MaxTries - s.Failures
|
||||
if s.Remaining < 0 {
|
||||
s.Remaining = 0
|
||||
}
|
||||
s.LockedOut = s.Remaining <= 0
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func ResetFaillock(user string) error {
|
||||
return exec.Command("faillock", "--user", user, "--reset").Run()
|
||||
}
|
||||
|
||||
func countFaillockLines(out string) int {
|
||||
n := 0
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "When") ||
|
||||
strings.HasPrefix(line, "Source") || strings.HasPrefix(line, "Date") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, ":") {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
176
src/auth/pam.go
Normal file
176
src/auth/pam.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// auth — PAM authentication with CGO bindings.
|
||||
//
|
||||
// Uses runtime.LockOSThread to prevent goroutine migration during
|
||||
// PAM transactions, as PAM modules rely on thread-local storage.
|
||||
//
|
||||
// Mirrors vendor/src/auth/pam.rs and vendor/src/auth/mod.rs.
|
||||
package auth
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -lpam
|
||||
#include <security/pam_appl.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
// Match the //export-generated signature.
|
||||
extern int latchd_pam_conv(int, struct pam_message **,
|
||||
struct pam_response **, void *);
|
||||
|
||||
// Helper to create the conv struct with the proper cast.
|
||||
static struct pam_conv makePAMConv(void *data) {
|
||||
struct pam_conv c;
|
||||
c.conv = (int (*)(int, const struct pam_message **,
|
||||
struct pam_response **, void *))latchd_pam_conv;
|
||||
c.appdata_ptr = data;
|
||||
return c;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
ouser "os/user"
|
||||
"runtime"
|
||||
"runtime/cgo"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type AuthError struct {
|
||||
Kind int
|
||||
Msg string
|
||||
}
|
||||
|
||||
const (
|
||||
ErrPAMService = iota
|
||||
ErrAccountValidation
|
||||
ErrHomeDirInvalid
|
||||
ErrShellInvalid
|
||||
ErrUsernameNotFound
|
||||
ErrSessionOpen
|
||||
)
|
||||
|
||||
func (e *AuthError) Error() string { return e.Msg }
|
||||
|
||||
type Credentials struct {
|
||||
Username string
|
||||
UID uint32
|
||||
PrimaryGID uint32
|
||||
AllGIDs []uint32
|
||||
HomeDir string
|
||||
Shell string
|
||||
pamHandle *C.pam_handle_t
|
||||
}
|
||||
|
||||
//export latchd_pam_conv
|
||||
func latchd_pam_conv(numMsg C.int, msg **C.struct_pam_message, resp **C.struct_pam_response, data unsafe.Pointer) C.int {
|
||||
h := *(*cgo.Handle)(data)
|
||||
pw := h.Value().(string)
|
||||
|
||||
r := (*C.struct_pam_response)(C.calloc(C.ulong(numMsg), C.sizeof_struct_pam_response))
|
||||
if r == nil {
|
||||
return C.PAM_BUF_ERR
|
||||
}
|
||||
*resp = r
|
||||
|
||||
s := unsafe.Slice((**C.struct_pam_response)(unsafe.Pointer(&r)), int(numMsg))
|
||||
for i := 0; i < int(numMsg); i++ {
|
||||
c := C.CString(pw)
|
||||
if c == nil {
|
||||
return C.PAM_BUF_ERR
|
||||
}
|
||||
s[i] = &C.struct_pam_response{resp: c, resp_retcode: 0}
|
||||
}
|
||||
return C.PAM_SUCCESS
|
||||
}
|
||||
|
||||
func Validate(user, pass, service string) (*Credentials, error) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
cSvc := C.CString(service)
|
||||
cUser := C.CString(user)
|
||||
defer C.free(unsafe.Pointer(cSvc))
|
||||
defer C.free(unsafe.Pointer(cUser))
|
||||
|
||||
h := cgo.NewHandle(pass)
|
||||
defer h.Delete()
|
||||
|
||||
conv := C.makePAMConv(unsafe.Pointer(&h))
|
||||
|
||||
var pamh *C.pam_handle_t
|
||||
ret := C.pam_start(cSvc, cUser, &conv, &pamh)
|
||||
if ret != C.PAM_SUCCESS {
|
||||
return nil, &AuthError{ErrPAMService, fmt.Sprintf("pam_start: %s", pamErr(pamh, ret))}
|
||||
}
|
||||
|
||||
ret = C.pam_authenticate(pamh, 0)
|
||||
if ret != C.PAM_SUCCESS {
|
||||
msg := fmt.Sprintf("auth failed: %s", pamErr(pamh, ret))
|
||||
C.pam_end(pamh, ret)
|
||||
return nil, &AuthError{ErrAccountValidation, msg}
|
||||
}
|
||||
|
||||
ret = C.pam_acct_mgmt(pamh, 0)
|
||||
if ret != C.PAM_SUCCESS {
|
||||
msg := fmt.Sprintf("account: %s", pamErr(pamh, ret))
|
||||
C.pam_end(pamh, ret)
|
||||
return nil, &AuthError{ErrAccountValidation, msg}
|
||||
}
|
||||
|
||||
u, err := ouser.Lookup(user)
|
||||
if err != nil {
|
||||
C.pam_end(pamh, ret)
|
||||
return nil, &AuthError{ErrUsernameNotFound, fmt.Sprintf("lookup %s: %v", user, err)}
|
||||
}
|
||||
|
||||
uid := atou32(u.Uid)
|
||||
gid := atou32(u.Gid)
|
||||
gids := []uint32{gid}
|
||||
if ids, err := u.GroupIds(); err == nil {
|
||||
for _, s := range ids {
|
||||
g := atou32(s)
|
||||
if g != gid {
|
||||
gids = append(gids, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Credentials{
|
||||
Username: user,
|
||||
UID: uid,
|
||||
PrimaryGID: gid,
|
||||
AllGIDs: gids,
|
||||
HomeDir: u.HomeDir,
|
||||
Shell: "/bin/sh",
|
||||
pamHandle: pamh,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Credentials) OpenSession() error {
|
||||
ret := C.pam_open_session(c.pamHandle, 0)
|
||||
if ret != C.PAM_SUCCESS {
|
||||
return &AuthError{ErrSessionOpen, fmt.Sprintf("session: %s", pamErr(c.pamHandle, ret))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Credentials) CloseSession() {
|
||||
if c.pamHandle != nil {
|
||||
C.pam_close_session(c.pamHandle, 0)
|
||||
C.pam_end(c.pamHandle, 0)
|
||||
c.pamHandle = nil
|
||||
}
|
||||
}
|
||||
|
||||
func pamErr(h *C.pam_handle_t, e C.int) string {
|
||||
return C.GoString(C.pam_strerror(h, e))
|
||||
}
|
||||
|
||||
func atou32(s string) uint32 {
|
||||
var n uint32
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
n = n*10 + uint32(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
76
src/auth/utmpx.go
Normal file
76
src/auth/utmpx.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// auth — UTMPX user accounting (glibc only).
|
||||
//
|
||||
// Mirrors vendor/src/auth/utmpx.rs.
|
||||
package auth
|
||||
|
||||
/*
|
||||
#include <utmpx.h>
|
||||
#include <string.h>
|
||||
#include <sys/time.h>
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type UtmpxSession struct {
|
||||
ok bool
|
||||
username string
|
||||
tty uint8
|
||||
}
|
||||
|
||||
func AddUtmpx(user string, tty uint8, pid uint32) *UtmpxSession {
|
||||
s := &UtmpxSession{username: user, tty: tty}
|
||||
|
||||
cUser := C.CString(user)
|
||||
defer C.free(unsafe.Pointer(cUser))
|
||||
|
||||
var ut C.struct_utmpx
|
||||
C.memset(unsafe.Pointer(&ut), 0, C.sizeof_struct_utmpx)
|
||||
|
||||
ut.ut_type = C.USER_PROCESS
|
||||
ut.ut_pid = C.pid_t(pid)
|
||||
|
||||
n := len(user)
|
||||
if n > 32 {
|
||||
n = 32
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
ut.ut_user[i] = C.char(user[i])
|
||||
}
|
||||
|
||||
ttyCh := byte('0' + tty)
|
||||
ut.ut_line[0] = 't'
|
||||
ut.ut_line[1] = 't'
|
||||
ut.ut_line[2] = 'y'
|
||||
ut.ut_line[3] = C.char(ttyCh)
|
||||
ut.ut_id[0] = C.char(ttyCh)
|
||||
|
||||
us := time.Now().UnixMicro()
|
||||
ut.ut_tv.tv_sec = C.__uint32_t(us / 1_000_000)
|
||||
ut.ut_tv.tv_usec = C.__int32_t(us % 1_000_000)
|
||||
|
||||
C.setutxent()
|
||||
C.pututxline(&ut)
|
||||
|
||||
s.ok = true
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *UtmpxSession) Close() {
|
||||
if !s.ok {
|
||||
return
|
||||
}
|
||||
|
||||
var ut C.struct_utmpx
|
||||
C.memset(unsafe.Pointer(&ut), 0, C.sizeof_struct_utmpx)
|
||||
ut.ut_type = C.DEAD_PROCESS
|
||||
|
||||
C.setutxent()
|
||||
C.pututxline(&ut)
|
||||
C.endutxent()
|
||||
|
||||
s.ok = false
|
||||
}
|
||||
309
src/config/config.go
Normal file
309
src/config/config.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type FocusBehaviour string
|
||||
|
||||
const (
|
||||
FocusFirstNonCached FocusBehaviour = "default"
|
||||
FocusNone FocusBehaviour = "no-focus"
|
||||
FocusEnvironment FocusBehaviour = "environment"
|
||||
FocusUsername FocusBehaviour = "username"
|
||||
FocusPassword FocusBehaviour = "password"
|
||||
)
|
||||
|
||||
type ShellLoginFlag string
|
||||
|
||||
const (
|
||||
ShellLoginNone ShellLoginFlag = "none"
|
||||
ShellLoginShort ShellLoginFlag = "short"
|
||||
ShellLoginLong ShellLoginFlag = "long"
|
||||
)
|
||||
|
||||
type BackgroundStyle struct {
|
||||
Color string `toml:"color"`
|
||||
ShowBorder bool `toml:"show_border"`
|
||||
BorderColor string `toml:"border_color"`
|
||||
}
|
||||
|
||||
type BackgroundConfig struct {
|
||||
ShowBackground bool `toml:"show_background"`
|
||||
Style BackgroundStyle `toml:"style"`
|
||||
}
|
||||
|
||||
type PowerControl struct {
|
||||
Hint string `toml:"hint"`
|
||||
HintColor string `toml:"hint_color"`
|
||||
HintModifiers string `toml:"hint_modifiers"`
|
||||
Key string `toml:"key"`
|
||||
Cmd string `toml:"cmd"`
|
||||
}
|
||||
|
||||
type PowerControlConfig struct {
|
||||
HintMargin uint16 `toml:"hint_margin"`
|
||||
BaseEntries []PowerControl `toml:"base_entries"`
|
||||
Entries []PowerControl `toml:"entries"`
|
||||
}
|
||||
|
||||
type SwitcherConfig struct {
|
||||
SwitcherVisibility string `toml:"switcher_visibility"`
|
||||
ToggleHint string `toml:"toggle_hint"`
|
||||
ToggleHintColor string `toml:"toggle_hint_color"`
|
||||
ToggleHintModifiers string `toml:"toggle_hint_modifiers"`
|
||||
IncludeTTYShell bool `toml:"include_tty_shell"`
|
||||
Remember bool `toml:"remember"`
|
||||
ShowMovers bool `toml:"show_movers"`
|
||||
MoverColor string `toml:"mover_color"`
|
||||
MoverColorFocused string `toml:"mover_color_focused"`
|
||||
MoverModifiers string `toml:"mover_modifiers"`
|
||||
MoverModifiersFocused string `toml:"mover_modifiers_focused"`
|
||||
LeftMover string `toml:"left_mover"`
|
||||
RightMover string `toml:"right_mover"`
|
||||
MoverMargin uint16 `toml:"mover_margin"`
|
||||
ShowNeighbours bool `toml:"show_neighbours"`
|
||||
NeighbourColor string `toml:"neighbour_color"`
|
||||
NeighbourColorFocused string `toml:"neighbour_color_focused"`
|
||||
NeighbourModifiers string `toml:"neighbour_modifiers"`
|
||||
NeighbourModifiersFocused string `toml:"neighbour_modifiers_focused"`
|
||||
NeighbourMargin uint16 `toml:"neighbour_margin"`
|
||||
SelectedColor string `toml:"selected_color"`
|
||||
SelectedColorFocused string `toml:"selected_color_focused"`
|
||||
SelectedModifiers string `toml:"selected_modifiers"`
|
||||
SelectedModifiersFocused string `toml:"selected_modifiers_focused"`
|
||||
MaxDisplayLength uint16 `toml:"max_display_length"`
|
||||
NoEnvsText string `toml:"no_envs_text"`
|
||||
NoEnvsColor string `toml:"no_envs_color"`
|
||||
NoEnvsColorFocused string `toml:"no_envs_color_focused"`
|
||||
NoEnvsModifiers string `toml:"no_envs_modifiers"`
|
||||
NoEnvsModifiersFocused string `toml:"no_envs_modifiers_focused"`
|
||||
}
|
||||
|
||||
type InputFieldStyle struct {
|
||||
ShowTitle bool `toml:"show_title"`
|
||||
Title string `toml:"title"`
|
||||
ShowBorder bool `toml:"show_border"`
|
||||
TitleColor string `toml:"title_color"`
|
||||
TitleColorFocused string `toml:"title_color_focused"`
|
||||
ContentColor string `toml:"content_color"`
|
||||
ContentColorFocused string `toml:"content_color_focused"`
|
||||
BorderColor string `toml:"border_color"`
|
||||
BorderColorFocused string `toml:"border_color_focused"`
|
||||
UseMaxWidth bool `toml:"use_max_width"`
|
||||
MaxWidth uint16 `toml:"max_width"`
|
||||
}
|
||||
|
||||
type UsernameFieldConfig struct {
|
||||
Remember bool `toml:"remember"`
|
||||
Style InputFieldStyle `toml:"style"`
|
||||
}
|
||||
|
||||
type PasswordFieldConfig struct {
|
||||
ReplacementChar string `toml:"content_replacement_character"`
|
||||
Style InputFieldStyle `toml:"style"`
|
||||
}
|
||||
|
||||
type X11Config struct {
|
||||
Display string `toml:"x11_display"`
|
||||
ServerTimeoutSecs uint16 `toml:"xserver_timeout_secs"`
|
||||
ServerLogPath string `toml:"xserver_log_path"`
|
||||
ServerPath string `toml:"xserver_path"`
|
||||
XauthPath string `toml:"xauth_path"`
|
||||
ScriptsPath string `toml:"scripts_path"`
|
||||
SetupPath string `toml:"xsetup_path"`
|
||||
SessionsPath string `toml:"xsessions_path"`
|
||||
}
|
||||
|
||||
type WaylandConfig struct {
|
||||
ScriptsPath string `toml:"scripts_path"`
|
||||
SessionsPath string `toml:"wayland_sessions_path"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
TTY uint8 `toml:"tty"`
|
||||
MainLogPath string `toml:"main_log_path"`
|
||||
ClientLogPath string `toml:"client_log_path"`
|
||||
CachePath string `toml:"cache_path"`
|
||||
DoLog bool `toml:"do_log"`
|
||||
PAMService string `toml:"pam_service"`
|
||||
SystemShell string `toml:"system_shell"`
|
||||
InitialPath string `toml:"initial_path"`
|
||||
ShellLoginFlag ShellLoginFlag `toml:"shell_login_flag"`
|
||||
FocusBehaviour FocusBehaviour `toml:"focus_behaviour"`
|
||||
Background BackgroundConfig `toml:"background"`
|
||||
PowerControls PowerControlConfig `toml:"power_controls"`
|
||||
Switcher SwitcherConfig `toml:"environment_switcher"`
|
||||
Username UsernameFieldConfig `toml:"username_field"`
|
||||
Password PasswordFieldConfig `toml:"password_field"`
|
||||
X11 X11Config `toml:"x11"`
|
||||
Wayland WaylandConfig `toml:"wayland"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
var c Config
|
||||
if err := toml.Unmarshal([]byte(defaultTOML), &c); err != nil {
|
||||
panic("default config: " + err.Error())
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func LoadPartial(filename string, vars map[string]string, dst *Config) error {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
if vars != nil {
|
||||
raw = []byte(resolveVars(string(raw), vars))
|
||||
}
|
||||
return toml.Unmarshal(raw, dst)
|
||||
}
|
||||
|
||||
func LoadVariables(filename string) (map[string]string, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
// Parse a flat TOML table of string values.
|
||||
vars := make(map[string]string)
|
||||
var table map[string]any
|
||||
if err := toml.Unmarshal(raw, &table); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", filename, err)
|
||||
}
|
||||
for k, v := range table {
|
||||
vars[k] = fmt.Sprint(v)
|
||||
}
|
||||
return vars, nil
|
||||
}
|
||||
|
||||
// ── variable substitution ───────────────────────────────────────────────
|
||||
|
||||
func resolveVars(in string, vars map[string]string) string {
|
||||
for k, v := range vars {
|
||||
in = strings.ReplaceAll(in, "$"+k, v)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// ── embedded default config ─────────────────────────────────────────────
|
||||
|
||||
const defaultTOML = `
|
||||
tty = 2
|
||||
main_log_path = "/var/log/latchd.log"
|
||||
client_log_path = "/var/log/latchd.client.log"
|
||||
cache_path = "/var/cache/latchd"
|
||||
do_log = true
|
||||
pam_service = "latchd"
|
||||
system_shell = "/bin/sh"
|
||||
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin"
|
||||
shell_login_flag = "short"
|
||||
focus_behaviour = "default"
|
||||
|
||||
[background]
|
||||
show_background = true
|
||||
|
||||
[background.style]
|
||||
color = "black"
|
||||
show_border = true
|
||||
border_color = "white"
|
||||
|
||||
[power_controls]
|
||||
hint_margin = 2
|
||||
entries = []
|
||||
|
||||
[[power_controls.base_entries]]
|
||||
hint = "Shutdown"
|
||||
hint_color = "dark gray"
|
||||
hint_modifiers = ""
|
||||
key = "F1"
|
||||
cmd = "systemctl poweroff -l"
|
||||
|
||||
[[power_controls.base_entries]]
|
||||
hint = "Reboot"
|
||||
hint_color = "dark gray"
|
||||
hint_modifiers = ""
|
||||
key = "F2"
|
||||
cmd = "systemctl reboot -l"
|
||||
|
||||
[environment_switcher]
|
||||
switcher_visibility = "visible"
|
||||
toggle_hint = "Switcher %key%"
|
||||
toggle_hint_color = "dark gray"
|
||||
toggle_hint_modifiers = ""
|
||||
include_tty_shell = false
|
||||
remember = true
|
||||
show_movers = true
|
||||
mover_color = "dark gray"
|
||||
mover_modifiers = ""
|
||||
mover_color_focused = "orange"
|
||||
mover_modifiers_focused = "bold"
|
||||
left_mover = "<"
|
||||
right_mover = ">"
|
||||
mover_margin = 1
|
||||
show_neighbours = true
|
||||
neighbour_color = "dark gray"
|
||||
neighbour_modifiers = ""
|
||||
neighbour_color_focused = "gray"
|
||||
neighbour_modifiers_focused = ""
|
||||
neighbour_margin = 1
|
||||
selected_color = "gray"
|
||||
selected_modifiers = "underlined"
|
||||
selected_color_focused = "white"
|
||||
selected_modifiers_focused = "bold"
|
||||
max_display_length = 8
|
||||
no_envs_text = "No environments..."
|
||||
no_envs_color = "white"
|
||||
no_envs_modifiers = ""
|
||||
no_envs_color_focused = "red"
|
||||
no_envs_modifiers_focused = ""
|
||||
|
||||
[username_field]
|
||||
remember = true
|
||||
|
||||
[username_field.style]
|
||||
show_title = true
|
||||
title = "Login"
|
||||
title_color = "white"
|
||||
content_color = "white"
|
||||
title_color_focused = "orange"
|
||||
content_color_focused = "orange"
|
||||
show_border = true
|
||||
border_color = "white"
|
||||
border_color_focused = "orange"
|
||||
use_max_width = true
|
||||
max_width = 48
|
||||
|
||||
[password_field]
|
||||
content_replacement_character = "*"
|
||||
|
||||
[password_field.style]
|
||||
show_title = true
|
||||
title = "Password"
|
||||
title_color = "white"
|
||||
content_color = "white"
|
||||
title_color_focused = "orange"
|
||||
content_color_focused = "orange"
|
||||
show_border = true
|
||||
border_color = "white"
|
||||
border_color_focused = "orange"
|
||||
use_max_width = true
|
||||
max_width = 48
|
||||
|
||||
[x11]
|
||||
x11_display = ":1"
|
||||
xserver_timeout_secs = 60
|
||||
xserver_log_path = "/var/log/latchd.xorg.log"
|
||||
xserver_path = "/usr/bin/X"
|
||||
xauth_path = "/usr/bin/xauth"
|
||||
scripts_path = "/etc/latchd/wms"
|
||||
xsetup_path = "/etc/latchd/xsetup.sh"
|
||||
xsessions_path = "/usr/share/xsessions"
|
||||
|
||||
[wayland]
|
||||
scripts_path = "/etc/latchd/wayland"
|
||||
wayland_sessions_path = "/usr/share/wayland-sessions"
|
||||
`
|
||||
28
src/go.mod
Normal file
28
src/go.mod
Normal file
@@ -0,0 +1,28 @@
|
||||
module latchd
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/charmbracelet/bubbles v0.18.0
|
||||
github.com/charmbracelet/bubbletea v0.26.6
|
||||
github.com/charmbracelet/lipgloss v0.11.0
|
||||
github.com/mattn/go-runewidth v0.0.15
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.1.2 // indirect
|
||||
github.com/charmbracelet/x/input v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/term v0.1.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
)
|
||||
40
src/go.sum
Normal file
40
src/go.sum
Normal file
@@ -0,0 +1,40 @@
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
||||
github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
|
||||
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
|
||||
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
|
||||
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
|
||||
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
|
||||
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
|
||||
github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
|
||||
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
|
||||
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
785
src/login/login.go
Normal file
785
src/login/login.go
Normal file
@@ -0,0 +1,785 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"latchd/auth"
|
||||
"latchd/config"
|
||||
"latchd/session"
|
||||
"latchd/tui/bg"
|
||||
)
|
||||
|
||||
// ── pixelated user avatar ───────────────────────────────────────────────
|
||||
|
||||
var avatarArt = []string{
|
||||
` ▄▄▄▄▄ `,
|
||||
` ▄███████▄ `,
|
||||
` ██ ██ ██ `,
|
||||
` ██ ██ `,
|
||||
` ██ ███ ██ `,
|
||||
` █████████ `,
|
||||
` ██ ██ ██ `,
|
||||
` ██ ██ `,
|
||||
}
|
||||
|
||||
// ── messages ─────────────────────────────────────────────────────────────
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
type authResult struct {
|
||||
ok bool
|
||||
err error
|
||||
faillock auth.FaillockStatus
|
||||
}
|
||||
|
||||
type adminBypassResult struct {
|
||||
ok bool
|
||||
err error
|
||||
}
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
modeNormal mode = iota
|
||||
modeSwitcher
|
||||
modeUsername
|
||||
modePassword
|
||||
modeAdminUser
|
||||
modeAdminPass
|
||||
)
|
||||
|
||||
// ── result (sent back to main on successful login) ──────────────────────
|
||||
|
||||
type Result struct {
|
||||
Username string
|
||||
Password string
|
||||
Env session.PostLoginEnv
|
||||
Config config.Config
|
||||
Preview bool
|
||||
}
|
||||
|
||||
// ── model ───────────────────────────────────────────────────────────────
|
||||
|
||||
type Model struct {
|
||||
cfg config.Config
|
||||
preview bool
|
||||
width int
|
||||
height int
|
||||
|
||||
automata *bg.Automata
|
||||
|
||||
envs []session.PostLoginEnv
|
||||
selIdx int
|
||||
|
||||
username string
|
||||
password string
|
||||
ucur int
|
||||
pcur int
|
||||
uscroll int
|
||||
pscroll int
|
||||
|
||||
adminUser string
|
||||
adminPass string
|
||||
aucur int
|
||||
apcur int
|
||||
lockedFor string
|
||||
|
||||
mode mode
|
||||
status string
|
||||
statusErr bool
|
||||
shakeOff int
|
||||
shakeLeft float64
|
||||
shakeDec float64
|
||||
spring float64
|
||||
|
||||
quitting bool
|
||||
result *Result
|
||||
}
|
||||
|
||||
func New(cfg config.Config, preview bool) *Model {
|
||||
raw := session.ScanEnvs(
|
||||
cfg.X11.SessionsPath, cfg.Wayland.SessionsPath,
|
||||
cfg.X11.ScriptsPath, cfg.Wayland.ScriptsPath,
|
||||
cfg.Switcher.IncludeTTYShell,
|
||||
)
|
||||
m := &Model{cfg: cfg, preview: preview, envs: raw}
|
||||
switch cfg.FocusBehaviour {
|
||||
case config.FocusEnvironment:
|
||||
if len(raw) > 0 {
|
||||
m.mode = modeSwitcher
|
||||
} else {
|
||||
m.mode = modeUsername
|
||||
}
|
||||
case config.FocusUsername:
|
||||
m.mode = modeUsername
|
||||
case config.FocusPassword:
|
||||
m.mode = modePassword
|
||||
default:
|
||||
if len(raw) > 0 {
|
||||
m.mode = modeSwitcher
|
||||
} else {
|
||||
m.mode = modeUsername
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return tea.Batch(tick(), tea.EnterAltScreen)
|
||||
}
|
||||
|
||||
// ── update ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
if m.automata != nil {
|
||||
m.automata.Resize(msg.Width, msg.Height)
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
case tickMsg:
|
||||
if m.automata != nil {
|
||||
m.automata.Tick()
|
||||
}
|
||||
m.tickShake()
|
||||
m.tickSpring()
|
||||
return m, tick()
|
||||
case authResult:
|
||||
return m.handleAuth(msg)
|
||||
case adminBypassResult:
|
||||
return m.handleBypass(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) tickShake() {
|
||||
if m.shakeLeft > 0.01 || m.shakeLeft < -0.01 {
|
||||
m.shakeDec += 0.05
|
||||
m.shakeLeft *= 0.82
|
||||
m.shakeOff = int(m.shakeLeft * 6.0 * (1.0 - m.shakeDec))
|
||||
if m.shakeDec >= 1.0 {
|
||||
m.shakeOff, m.shakeLeft, m.shakeDec = 0, 0, 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) tickSpring() {
|
||||
if m.spring > 0.5 || m.spring < -0.5 {
|
||||
m.spring *= 0.75
|
||||
} else {
|
||||
m.spring = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleAuth(msg authResult) (tea.Model, tea.Cmd) {
|
||||
if msg.ok {
|
||||
m.result = &Result{
|
||||
Username: m.username, Password: m.password,
|
||||
Env: m.selectedEnv(), Config: m.cfg, Preview: m.preview,
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.status = "incorrect username or password"
|
||||
m.statusErr = true
|
||||
m.shakeLeft = 1.0
|
||||
m.shakeDec = 0
|
||||
m.password = ""
|
||||
m.pcur = 0
|
||||
m.pscroll = 0
|
||||
if msg.faillock.Enabled && msg.faillock.LockedOut {
|
||||
m.lockedFor = m.username
|
||||
m.status = fmt.Sprintf("account locked — %d/%d attempts [Ctrl+U to unlock]",
|
||||
msg.faillock.Failures, msg.faillock.MaxTries)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) handleBypass(msg adminBypassResult) (tea.Model, tea.Cmd) {
|
||||
if msg.ok {
|
||||
m.lockedFor = ""
|
||||
m.mode = modeNormal
|
||||
m.adminUser = ""
|
||||
m.adminPass = ""
|
||||
m.status = "account unlocked — try logging in again"
|
||||
m.statusErr = false
|
||||
} else {
|
||||
m.status = "override failed: " + msg.err.Error()
|
||||
m.statusErr = true
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := msg.String()
|
||||
|
||||
if m.mode == modeAdminUser || m.mode == modeAdminPass {
|
||||
return m.adminKey(msg)
|
||||
}
|
||||
|
||||
switch k {
|
||||
case "esc":
|
||||
if m.preview {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.mode, m.status = modeNormal, ""
|
||||
return m, nil
|
||||
case "ctrl+c":
|
||||
if m.preview {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+u":
|
||||
if m.lockedFor != "" {
|
||||
m.mode = modeAdminUser
|
||||
m.status = "admin unlock · enter username"
|
||||
m.statusErr = false
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
switch k {
|
||||
case "tab", "down", "ctrl+n":
|
||||
m.mode = m.nextMode()
|
||||
case "shift+tab", "up", "ctrl+p":
|
||||
m.mode = m.prevMode()
|
||||
}
|
||||
|
||||
if k == "enter" && m.mode == modePassword {
|
||||
return m, m.loginCmd()
|
||||
}
|
||||
|
||||
if len(k) >= 2 && k[0] == 'f' {
|
||||
m.handleFn(k)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.mode {
|
||||
case modeSwitcher:
|
||||
switch k {
|
||||
case "left", "h":
|
||||
if m.selIdx > 0 {
|
||||
m.selIdx--
|
||||
m.spring = -4
|
||||
}
|
||||
case "right", "l":
|
||||
if m.selIdx < len(m.envs)-1 {
|
||||
m.selIdx++
|
||||
m.spring = 4
|
||||
}
|
||||
}
|
||||
case modeUsername:
|
||||
handleField(k, &m.username, &m.ucur, &m.uscroll)
|
||||
case modePassword:
|
||||
handleField(k, &m.password, &m.pcur, &m.pscroll)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) adminKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := msg.String()
|
||||
switch k {
|
||||
case "esc":
|
||||
m.mode, m.adminUser, m.adminPass, m.status = modeNormal, "", "", ""
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.mode == modeAdminUser && m.adminUser != "" {
|
||||
m.mode = modeAdminPass
|
||||
m.status = "admin unlock · enter password"
|
||||
return m, nil
|
||||
}
|
||||
if m.mode == modeAdminPass && m.adminPass != "" {
|
||||
return m, m.adminBypassCmd()
|
||||
}
|
||||
case "tab":
|
||||
if m.mode == modeAdminUser {
|
||||
m.mode = modeAdminPass
|
||||
} else {
|
||||
m.mode = modeAdminUser
|
||||
}
|
||||
default:
|
||||
if m.mode == modeAdminUser {
|
||||
handleField(k, &m.adminUser, &m.aucur, &m.aucur)
|
||||
} else {
|
||||
handleField(k, &m.adminPass, &m.apcur, &m.apcur)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) nextMode() mode {
|
||||
switch m.mode {
|
||||
case modeNormal:
|
||||
if len(m.envs) > 0 {
|
||||
return modeSwitcher
|
||||
}
|
||||
return modeUsername
|
||||
case modeSwitcher:
|
||||
return modeUsername
|
||||
case modeUsername:
|
||||
return modePassword
|
||||
}
|
||||
return m.mode
|
||||
}
|
||||
|
||||
func (m *Model) prevMode() mode {
|
||||
switch m.mode {
|
||||
case modeSwitcher:
|
||||
return modeNormal
|
||||
case modeUsername:
|
||||
if len(m.envs) > 0 {
|
||||
return modeSwitcher
|
||||
}
|
||||
return modeNormal
|
||||
case modePassword:
|
||||
return modeUsername
|
||||
}
|
||||
return modeNormal
|
||||
}
|
||||
|
||||
func (m *Model) handleFn(k string) {
|
||||
for _, pc := range m.cfg.PowerControls.BaseEntries {
|
||||
if strings.EqualFold(k, pc.Key) {
|
||||
exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start()
|
||||
}
|
||||
}
|
||||
for _, pc := range m.cfg.PowerControls.Entries {
|
||||
if strings.EqualFold(k, pc.Key) {
|
||||
exec.Command(m.cfg.SystemShell, "-c", pc.Cmd).Start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) selectedEnv() session.PostLoginEnv {
|
||||
if len(m.envs) == 0 || m.selIdx >= len(m.envs) {
|
||||
return session.PostLoginEnv{}
|
||||
}
|
||||
return m.envs[m.selIdx]
|
||||
}
|
||||
|
||||
// ── commands ────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Model) loginCmd() tea.Cmd {
|
||||
if m.preview {
|
||||
return func() tea.Msg { time.Sleep(800 * time.Millisecond); return authResult{ok: true} }
|
||||
}
|
||||
user, pass, svc := m.username, m.password, m.cfg.PAMService
|
||||
return func() tea.Msg {
|
||||
_, err := auth.Validate(user, pass, svc)
|
||||
if err == nil {
|
||||
return authResult{ok: true}
|
||||
}
|
||||
fs, _ := auth.GetFaillockStatus(user)
|
||||
return authResult{ok: false, err: err, faillock: fs}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) adminBypassCmd() tea.Cmd {
|
||||
au, ap, tgt := m.adminUser, m.adminPass, m.lockedFor
|
||||
return func() tea.Msg {
|
||||
if _, err := auth.Validate(au, ap, "su"); err != nil {
|
||||
return adminBypassResult{ok: false, err: fmt.Errorf("admin auth failed")}
|
||||
}
|
||||
if err := auth.ResetFaillock(tgt); err != nil {
|
||||
return adminBypassResult{ok: false, err: fmt.Errorf("faillock: %w", err)}
|
||||
}
|
||||
return adminBypassResult{ok: true}
|
||||
}
|
||||
}
|
||||
|
||||
// ── queries ────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Model) Result() *Result { return m.result }
|
||||
func (m *Model) Quitting() bool { return m.quitting }
|
||||
func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a }
|
||||
|
||||
// ── view ───────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
accent = lipgloss.Color("208") // orange
|
||||
subtle = lipgloss.Color("8") // grey
|
||||
bright = lipgloss.Color("15") // white
|
||||
errCol = lipgloss.Color("1") // red
|
||||
infoCol = lipgloss.Color("3") // yellow
|
||||
greenCol = lipgloss.Color("2") // green
|
||||
|
||||
avatarStyle = lipgloss.NewStyle().Foreground(accent)
|
||||
|
||||
fieldBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(subtle).
|
||||
Padding(0, 1).
|
||||
Width(34)
|
||||
|
||||
fieldBoxFocused = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(accent).
|
||||
Padding(0, 1).
|
||||
Width(34)
|
||||
|
||||
fieldLabel = lipgloss.NewStyle().Foreground(subtle).Padding(0, 0)
|
||||
fieldText = lipgloss.NewStyle().Foreground(bright)
|
||||
fieldFocus = lipgloss.NewStyle().Foreground(accent)
|
||||
|
||||
swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true)
|
||||
swNeighbour = lipgloss.NewStyle().Foreground(subtle)
|
||||
swMover = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||
swDot = lipgloss.NewStyle().Foreground(subtle)
|
||||
swDotActive = lipgloss.NewStyle().Foreground(accent)
|
||||
|
||||
powerHint = lipgloss.NewStyle().Foreground(subtle).Faint(true)
|
||||
|
||||
capsOnStyle = lipgloss.NewStyle().Foreground(greenCol).Bold(true)
|
||||
capsOffStyle = lipgloss.NewStyle().Foreground(subtle).Faint(true)
|
||||
|
||||
statusErrStyle = lipgloss.NewStyle().Foreground(errCol)
|
||||
statusInfoStyle = lipgloss.NewStyle().Foreground(infoCol)
|
||||
|
||||
adminLabel = lipgloss.NewStyle().Foreground(infoCol).Bold(true)
|
||||
)
|
||||
|
||||
func (m *Model) View() string {
|
||||
if m.quitting || m.result != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := m.renderLayout()
|
||||
|
||||
if m.automata != nil && m.width > 0 && m.height > 0 {
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
||||
content,
|
||||
lipgloss.WithWhitespaceChars(" "),
|
||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
|
||||
)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (m *Model) renderLayout() string {
|
||||
// top bar: power hints
|
||||
top := m.renderPowerHints()
|
||||
top = lipgloss.NewStyle().Width(80).Align(lipgloss.Left).Render(top)
|
||||
|
||||
// avatar + form side by side
|
||||
avatar := m.renderAvatar()
|
||||
form := m.renderForm()
|
||||
mid := lipgloss.JoinHorizontal(lipgloss.Top, avatar, " ", form)
|
||||
|
||||
// DE switcher below
|
||||
sw := m.renderSwitcherDots()
|
||||
|
||||
// caps lock
|
||||
caps := m.renderCapsLock()
|
||||
|
||||
// admin unlock
|
||||
var admin string
|
||||
if m.mode == modeAdminUser || m.mode == modeAdminPass {
|
||||
admin = m.renderAdminForm()
|
||||
}
|
||||
|
||||
// status
|
||||
st := m.renderStatus()
|
||||
|
||||
var parts []string
|
||||
parts = append(parts, top)
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, mid)
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, sw)
|
||||
if caps != "" {
|
||||
parts = append(parts, caps)
|
||||
}
|
||||
if admin != "" {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, admin)
|
||||
}
|
||||
if st != "" {
|
||||
parts = append(parts, st)
|
||||
}
|
||||
|
||||
joined := lipgloss.JoinVertical(lipgloss.Center, parts...)
|
||||
if m.shakeOff != 0 {
|
||||
pad := strings.Repeat(" ", abs(m.shakeOff))
|
||||
if m.shakeOff > 0 {
|
||||
joined = pad + joined
|
||||
}
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
func (m *Model) renderPowerHints() string {
|
||||
var hints []string
|
||||
for _, pc := range m.cfg.PowerControls.BaseEntries {
|
||||
hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint))
|
||||
}
|
||||
for _, pc := range m.cfg.PowerControls.Entries {
|
||||
hints = append(hints, powerHint.Render(pc.Key+" "+pc.Hint))
|
||||
}
|
||||
return strings.Join(hints, " ")
|
||||
}
|
||||
|
||||
func (m *Model) renderAvatar() string {
|
||||
var lines []string
|
||||
for _, row := range avatarArt {
|
||||
lines = append(lines, avatarStyle.Render(row))
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
|
||||
func (m *Model) renderForm() string {
|
||||
userLabel := fieldLabel.Render("username")
|
||||
if m.mode == modeUsername {
|
||||
userLabel = fieldFocus.Render("▸ username")
|
||||
}
|
||||
userVal := m.username
|
||||
if userVal == "" {
|
||||
userVal = " "
|
||||
}
|
||||
ubox := fieldBoxFocused
|
||||
utxt := fieldFocus
|
||||
if m.mode != modeUsername {
|
||||
ubox = fieldBox
|
||||
utxt = fieldText
|
||||
}
|
||||
userField := ubox.Render(userLabel + "\n" + utxt.Render(userVal))
|
||||
|
||||
passLabel := fieldLabel.Render("password")
|
||||
if m.mode == modePassword {
|
||||
passLabel = fieldFocus.Render("▸ password")
|
||||
}
|
||||
passVal := strings.Repeat("•", len(m.password))
|
||||
if passVal == "" {
|
||||
passVal = " "
|
||||
}
|
||||
pbox := fieldBoxFocused
|
||||
ptxt := fieldFocus
|
||||
if m.mode != modePassword {
|
||||
pbox = fieldBox
|
||||
ptxt = fieldText
|
||||
}
|
||||
passField := pbox.Render(passLabel + "\n" + ptxt.Render(passVal))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, userField, "", passField)
|
||||
}
|
||||
|
||||
func (m *Model) renderSwitcherDots() string {
|
||||
if len(m.envs) == 0 {
|
||||
return swNeighbour.Align(lipgloss.Center).Render("no desktop environments found")
|
||||
}
|
||||
|
||||
cfg := m.cfg.Switcher
|
||||
w := int(cfg.MaxDisplayLength)
|
||||
|
||||
var parts []string
|
||||
|
||||
if cfg.ShowMovers && m.selIdx > 0 {
|
||||
parts = append(parts, swMover.Render(cfg.LeftMover+" "))
|
||||
}
|
||||
|
||||
if cfg.ShowNeighbours && m.selIdx > 0 {
|
||||
parts = append(parts, swNeighbour.Render(padTrim(m.envs[m.selIdx-1].Title, w)+" "))
|
||||
}
|
||||
|
||||
cur := swSelected.Render(padTrim(m.envs[m.selIdx].Title, w))
|
||||
parts = append(parts, cur)
|
||||
|
||||
if cfg.ShowNeighbours && m.selIdx < len(m.envs)-1 {
|
||||
parts = append(parts, swNeighbour.Render(" "+padTrim(m.envs[m.selIdx+1].Title, w)))
|
||||
}
|
||||
|
||||
if cfg.ShowMovers && m.selIdx < len(m.envs)-1 {
|
||||
parts = append(parts, swMover.Render(" "+cfg.RightMover))
|
||||
}
|
||||
|
||||
swLine := lipgloss.JoinHorizontal(lipgloss.Center, parts...)
|
||||
|
||||
// Dot indicators
|
||||
var dots []string
|
||||
for i := 0; i < len(m.envs); i++ {
|
||||
if i == m.selIdx {
|
||||
dots = append(dots, swDotActive.Render("●"))
|
||||
} else {
|
||||
dots = append(dots, swDot.Render("·"))
|
||||
}
|
||||
}
|
||||
dotLine := strings.Join(dots, " ")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Center, swLine, dotLine)
|
||||
}
|
||||
|
||||
func (m *Model) renderCapsLock() string {
|
||||
if capsLockOn() {
|
||||
return capsOnStyle.Align(lipgloss.Center).Render("⬤ Caps Lock")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Model) renderAdminForm() string {
|
||||
label := adminLabel.Render("── admin override ──")
|
||||
|
||||
auLabel := fieldLabel.Render("admin username")
|
||||
if m.mode == modeAdminUser {
|
||||
auLabel = fieldFocus.Render("▸ admin username")
|
||||
}
|
||||
auVal := m.adminUser
|
||||
if auVal == "" {
|
||||
auVal = " "
|
||||
}
|
||||
auBox := fieldBox
|
||||
if m.mode == modeAdminUser {
|
||||
auBox = fieldBoxFocused
|
||||
}
|
||||
auField := auBox.Render(auLabel + "\n" + fieldText.Render(auVal))
|
||||
|
||||
apLabel := fieldLabel.Render("admin password")
|
||||
if m.mode == modeAdminPass {
|
||||
apLabel = fieldFocus.Render("▸ admin password")
|
||||
}
|
||||
apVal := strings.Repeat("•", len(m.adminPass))
|
||||
if apVal == "" {
|
||||
apVal = " "
|
||||
}
|
||||
apBox := fieldBox
|
||||
if m.mode == modeAdminPass {
|
||||
apBox = fieldBoxFocused
|
||||
}
|
||||
apField := apBox.Render(apLabel + "\n" + fieldText.Render(apVal))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Center,
|
||||
label, "", auField, "", apField,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Model) renderStatus() string {
|
||||
if m.status == "" {
|
||||
return ""
|
||||
}
|
||||
st := statusInfoStyle
|
||||
if m.statusErr {
|
||||
st = statusErrStyle
|
||||
}
|
||||
return st.Align(lipgloss.Center).Width(70).Render(m.status)
|
||||
}
|
||||
|
||||
// ── caps lock detection ────────────────────────────────────────────────
|
||||
|
||||
const kdgkbled = 0x4B64
|
||||
|
||||
func capsLockOn() bool {
|
||||
fd := int(os.Stdin.Fd())
|
||||
var leds int32
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
|
||||
uintptr(fd), kdgkbled, uintptr(unsafe.Pointer(&leds)))
|
||||
if errno != 0 {
|
||||
return false
|
||||
}
|
||||
return leds&0x04 != 0
|
||||
}
|
||||
|
||||
// ── field editing ──────────────────────────────────────────────────────
|
||||
|
||||
func handleField(k string, buf *string, cur, scroll *int) {
|
||||
switch {
|
||||
case k == "backspace" || k == "ctrl+h":
|
||||
if *cur == 0 && *scroll == 0 {
|
||||
return
|
||||
}
|
||||
moveLeft(cur, scroll, *buf)
|
||||
r := []rune(*buf)
|
||||
idx := *scroll + *cur
|
||||
if idx < len(r) {
|
||||
*buf = string(append(r[:idx], r[idx+1:]...))
|
||||
}
|
||||
case k == "delete" || k == "ctrl+d":
|
||||
r := []rune(*buf)
|
||||
idx := *scroll + *cur
|
||||
if idx < len(r) {
|
||||
*buf = string(append(r[:idx], r[idx+1:]...))
|
||||
}
|
||||
case k == "ctrl+a":
|
||||
*cur, *scroll = 0, 0
|
||||
case k == "ctrl+e":
|
||||
*cur, *scroll = len([]rune(*buf)), 0
|
||||
case k == "ctrl+l", k == "ctrl+u":
|
||||
*buf, *cur, *scroll = "", 0, 0
|
||||
case k == "left" || k == "ctrl+b":
|
||||
moveLeft(cur, scroll, *buf)
|
||||
case k == "right" || k == "ctrl+f":
|
||||
moveRight(cur, scroll, *buf)
|
||||
default:
|
||||
if len(k) == 1 {
|
||||
r := []rune(*buf)
|
||||
idx := *scroll + *cur
|
||||
if idx > len(r) {
|
||||
idx = len(r)
|
||||
}
|
||||
ch := []rune(k)[0]
|
||||
r = append(r[:idx], append([]rune{ch}, r[idx:]...)...)
|
||||
*buf = string(r)
|
||||
if *cur >= 30 {
|
||||
*scroll++
|
||||
} else {
|
||||
*cur++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveLeft(cur, scroll *int, buf string) {
|
||||
if *cur > 0 {
|
||||
*cur--
|
||||
return
|
||||
}
|
||||
if *scroll > 0 {
|
||||
*scroll--
|
||||
}
|
||||
}
|
||||
|
||||
func moveRight(cur, scroll *int, buf string) {
|
||||
if *scroll+*cur >= len([]rune(buf)) {
|
||||
return
|
||||
}
|
||||
if *cur >= 30 {
|
||||
*scroll++
|
||||
} else {
|
||||
*cur++
|
||||
}
|
||||
}
|
||||
|
||||
func padTrim(s string, w int) string {
|
||||
if len(s) > w {
|
||||
return s[:w]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ── tick ────────────────────────────────────────────────────────────────
|
||||
|
||||
func tick() tea.Cmd {
|
||||
return tea.Tick(time.Second/30, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
260
src/main.go
Normal file
260
src/main.go
Normal file
@@ -0,0 +1,260 @@
|
||||
// latchd — TUI display/login manager for GNU/Linux and BSD.
|
||||
//
|
||||
// Supports TTY, X11, and Wayland sessions with PAM authentication.
|
||||
// Go rewrite of Lemurs (Rust source under vendor/).
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"latchd/auth"
|
||||
"latchd/config"
|
||||
"latchd/login"
|
||||
"latchd/tui/bg"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "0.1.0"
|
||||
Commit = "unknown"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
|
||||
const defaultConfig = "/etc/latchd/config.toml"
|
||||
const defaultVars = "/etc/latchd/variables.toml"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
configPath string
|
||||
varsPath string
|
||||
ttyNum int
|
||||
preview bool
|
||||
noLog bool
|
||||
showConf bool
|
||||
printVer bool
|
||||
xsessions string
|
||||
wlsessions string
|
||||
initPath string
|
||||
)
|
||||
flag.StringVar(&configPath, "config", defaultConfig, "")
|
||||
flag.StringVar(&configPath, "c", defaultConfig, "")
|
||||
flag.StringVar(&varsPath, "variables", defaultVars, "")
|
||||
flag.StringVar(&varsPath, "v", defaultVars, "")
|
||||
flag.IntVar(&ttyNum, "tty", 0, "")
|
||||
flag.BoolVar(&preview, "preview", false, "")
|
||||
flag.BoolVar(&noLog, "no-log", false, "")
|
||||
flag.BoolVar(&showConf, "show-config", false, "")
|
||||
flag.BoolVar(&printVer, "version", false, "")
|
||||
flag.BoolVar(&printVer, "V", false, "")
|
||||
flag.StringVar(&xsessions, "xsessions", "", "")
|
||||
flag.StringVar(&wlsessions, "wlsessions", "", "")
|
||||
flag.StringVar(&initPath, "initial-path", "", "")
|
||||
flag.Parse()
|
||||
|
||||
if printVer {
|
||||
fmt.Printf("latchd %s commit %s built %s\n", Version, Commit, BuildTime)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
cfg := config.Default()
|
||||
if initPath != "" {
|
||||
cfg.InitialPath = initPath
|
||||
}
|
||||
if xsessions != "" {
|
||||
cfg.X11.SessionsPath = xsessions
|
||||
}
|
||||
if wlsessions != "" {
|
||||
cfg.Wayland.SessionsPath = wlsessions
|
||||
}
|
||||
vars, _ := config.LoadVariables(varsPath)
|
||||
if err := config.LoadPartial(configPath, vars, &cfg); err != nil {
|
||||
if configPath != defaultConfig {
|
||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if showConf {
|
||||
fmt.Printf("tty = %d\npam_service = %q\nshell = %q\nfocus = %q\n",
|
||||
cfg.TTY, cfg.PAMService, cfg.SystemShell, cfg.FocusBehaviour)
|
||||
os.Exit(0)
|
||||
}
|
||||
if !noLog && !preview {
|
||||
setupLog(cfg.MainLogPath)
|
||||
}
|
||||
if !preview {
|
||||
if _, ok := os.LookupEnv("XDG_SESSION_TYPE"); ok {
|
||||
fmt.Fprintln(os.Stderr, "latchd: already in a session — use --preview")
|
||||
os.Exit(1)
|
||||
}
|
||||
if os.Getuid() != 0 {
|
||||
fmt.Fprintf(os.Stderr, "latchd: must be root (uid %d)\n", os.Getuid())
|
||||
os.Exit(1)
|
||||
}
|
||||
if ttyNum != 0 {
|
||||
cfg.TTY = uint8(ttyNum)
|
||||
}
|
||||
if err := chvt(int(cfg.TTY)); err != nil {
|
||||
log.Printf("chvt %d: %v", cfg.TTY, err)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
res := launchUI(cfg, preview)
|
||||
if res == nil {
|
||||
break
|
||||
}
|
||||
if err := spawnSession(res); err != nil {
|
||||
log.Printf("session: %v", err)
|
||||
}
|
||||
if !preview {
|
||||
chvt(int(cfg.TTY))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func launchUI(cfg config.Config, preview bool) *login.Result {
|
||||
m := login.New(cfg, preview)
|
||||
if cfg.Background.ShowBackground {
|
||||
m.SetAutomata(bg.NewAutomata(80, 24))
|
||||
}
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
final, err := p.Run()
|
||||
if err != nil {
|
||||
log.Printf("tui: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
lm, ok := final.(*login.Model)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return lm.Result()
|
||||
}
|
||||
|
||||
func spawnSession(res *login.Result) error {
|
||||
if res.Preview {
|
||||
log.Println("preview — skipping real session")
|
||||
return nil
|
||||
}
|
||||
log.Printf("spawning %s → %s (%s)", res.Username, res.Env.Title, res.Env.Kind)
|
||||
creds, err := auth.Validate(res.Username, res.Password, res.Config.PAMService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: %w", err)
|
||||
}
|
||||
defer creds.CloseSession()
|
||||
|
||||
pid, err := syscall.ForkExec("/proc/self/exe", []string{
|
||||
"latchd-session",
|
||||
"--user", creds.Username,
|
||||
"--uid", fmt.Sprintf("%d", creds.UID),
|
||||
"--gid", fmt.Sprintf("%d", creds.PrimaryGID),
|
||||
"--home", creds.HomeDir,
|
||||
"--shell", creds.Shell,
|
||||
"--env-kind", res.Env.Kind,
|
||||
"--env-exec", res.Env.XinitrcPath,
|
||||
"--tty", fmt.Sprintf("%d", res.Config.TTY),
|
||||
}, &syscall.ProcAttr{
|
||||
Env: os.Environ(),
|
||||
Dir: creds.HomeDir,
|
||||
Files: []uintptr{0, 1, 2},
|
||||
Sys: &syscall.SysProcAttr{Setsid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("fork: %w", err)
|
||||
}
|
||||
|
||||
creds.OpenSession()
|
||||
|
||||
var ws syscall.WaitStatus
|
||||
if _, err := syscall.Wait4(pid, &ws, 0, nil); err != nil {
|
||||
return fmt.Errorf("wait: %w", err)
|
||||
}
|
||||
log.Printf("session pid %d exited %d", pid, ws.ExitStatus())
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupLog(path string) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "log %s: %v\n", path, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.SetOutput(io.MultiWriter(f, os.Stderr))
|
||||
log.SetFlags(log.LstdFlags)
|
||||
}
|
||||
|
||||
// ── chvt ────────────────────────────────────────────────────────────────
|
||||
|
||||
const vtA = 0x5606
|
||||
const vtW = 0x5607
|
||||
const kbt = 0x4B33
|
||||
const kb1 = 0x02
|
||||
const kb2 = 0x01
|
||||
|
||||
func isCon(fd uintptr) bool {
|
||||
var a int32
|
||||
_, _, e := syscall.Syscall(syscall.SYS_IOCTL, fd, kbt, uintptr(unsafe.Pointer(&a)))
|
||||
return e == 0 && (a == kb1 || a == kb2)
|
||||
}
|
||||
|
||||
func openCon(fn string) (int, error) {
|
||||
for _, fl := range []int{os.O_RDWR, os.O_RDONLY, os.O_WRONLY} {
|
||||
fd, err := syscall.Open(fn, fl, 0)
|
||||
if err != nil {
|
||||
if err == syscall.EACCES {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if isCon(uintptr(fd)) {
|
||||
return fd, nil
|
||||
}
|
||||
syscall.Close(fd)
|
||||
}
|
||||
return 0, fmt.Errorf("openCon: %s", fn)
|
||||
}
|
||||
|
||||
func getCon() (int, error) {
|
||||
for _, p := range []string{"/dev/tty", "/dev/tty0", "/dev/vc/0", "/dev/console"} {
|
||||
if fd, err := openCon(p); err == nil {
|
||||
return fd, nil
|
||||
}
|
||||
}
|
||||
for fd := 0; fd < 3; fd++ {
|
||||
if isCon(uintptr(fd)) {
|
||||
return fd, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no console")
|
||||
}
|
||||
|
||||
func chvt(n int) error {
|
||||
fd, err := getCon()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), vtA, uintptr(n)); e != 0 {
|
||||
return fmt.Errorf("VT_ACTIVATE")
|
||||
}
|
||||
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), vtW, uintptr(n)); e != 0 {
|
||||
return fmt.Errorf("VT_WAITACTIVE")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT)
|
||||
go func() {
|
||||
<-ch
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
64
src/session/env.go
Normal file
64
src/session/env.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// session — environment variable setup for latchd sessions.
|
||||
//
|
||||
// Mirrors vendor/src/post_login/env_variables.rs.
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func SetBasicVariables(user, home, shell, path string) {
|
||||
os.Chdir(home)
|
||||
os.Setenv("HOME", home)
|
||||
os.Setenv("SHELL", shell)
|
||||
os.Setenv("USER", user)
|
||||
os.Setenv("LOGNAME", user)
|
||||
os.Setenv("PATH", path)
|
||||
}
|
||||
|
||||
func SetDisplay(display string) {
|
||||
os.Setenv("DISPLAY", display)
|
||||
}
|
||||
|
||||
func RemoveXDG() {
|
||||
for _, v := range []string{
|
||||
"XDG_SESSION_CLASS", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP",
|
||||
"XDG_SEAT", "XDG_VTNR", "XDG_RUNTIME_DIR", "XDG_SESSION_ID",
|
||||
"XDG_CONFIG_DIR", "XDG_CACHE_HOME", "XDG_DATA_HOME",
|
||||
"XDG_STATE_HOME", "XDG_DATA_DIRS", "XDG_CONFIG_DIRS",
|
||||
} {
|
||||
os.Unsetenv(v)
|
||||
}
|
||||
}
|
||||
|
||||
func SetSessionParams(sessionType string) {
|
||||
os.Setenv("XDG_SESSION_CLASS", "user")
|
||||
os.Setenv("XDG_SESSION_TYPE", sessionType)
|
||||
}
|
||||
|
||||
func SetSeatVars(tty uint8) {
|
||||
setOrOwn("XDG_SEAT", "seat0")
|
||||
setOrOwn("XDG_VTNR", strconv.Itoa(int(tty)))
|
||||
}
|
||||
|
||||
func SetSessionVars(uid uint32) {
|
||||
setOrOwn("XDG_RUNTIME_DIR", fmt.Sprintf("/run/user/%d", uid))
|
||||
setOrOwn("XDG_SESSION_ID", "1")
|
||||
}
|
||||
|
||||
func SetXDGCommonPaths(home string) {
|
||||
setOrOwn("XDG_CONFIG_HOME", home+"/.config")
|
||||
setOrOwn("XDG_CACHE_HOME", home+"/.cache")
|
||||
setOrOwn("XDG_DATA_HOME", home+"/.local/share")
|
||||
setOrOwn("XDG_STATE_HOME", home+"/.local/state")
|
||||
setOrOwn("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
|
||||
setOrOwn("XDG_CONFIG_DIRS", "/etc/xdg")
|
||||
}
|
||||
|
||||
func setOrOwn(k, v string) {
|
||||
if _, ok := os.LookupEnv(k); !ok {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
198
src/session/exec.go
Normal file
198
src/session/exec.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// session — process execution and session spawning.
|
||||
//
|
||||
// Mirrors vendor/src/post_login/mod.rs and wait_with_log.rs.
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"latchd/config"
|
||||
)
|
||||
|
||||
type PostLoginEnv struct {
|
||||
Kind string // "x11", "wayland", "tty"
|
||||
Title string
|
||||
XinitrcPath string
|
||||
ScriptPath string
|
||||
}
|
||||
|
||||
type SpawnedEnv struct {
|
||||
Kind string
|
||||
Server *exec.Cmd
|
||||
Client *exec.Cmd
|
||||
logFd *os.File
|
||||
}
|
||||
|
||||
const logLimit = 67_108_864
|
||||
|
||||
type limitWriter struct {
|
||||
w io.Writer
|
||||
n int64
|
||||
max int64
|
||||
}
|
||||
|
||||
func (l *limitWriter) Write(p []byte) (int, error) {
|
||||
if l.n >= l.max {
|
||||
return len(p), nil
|
||||
}
|
||||
rem := l.max - l.n
|
||||
if int64(len(p)) > rem {
|
||||
p = p[:rem]
|
||||
}
|
||||
n, err := l.w.Write(p)
|
||||
l.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func Spawn(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config) (*SpawnedEnv, error) {
|
||||
var loginFlag string
|
||||
switch cfg.ShellLoginFlag {
|
||||
case config.ShellLoginShort:
|
||||
loginFlag = "-l"
|
||||
case config.ShellLoginLong:
|
||||
loginFlag = "--login"
|
||||
}
|
||||
|
||||
switch env.Kind {
|
||||
case "x11":
|
||||
return spawnX11(env, uid, gid, groups, cfg, loginFlag)
|
||||
case "wayland":
|
||||
return spawnWayland(env, uid, gid, groups, cfg, loginFlag)
|
||||
default:
|
||||
return spawnTTY(env, uid, gid, groups, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func spawnX11(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config, loginFlag string) (*SpawnedEnv, error) {
|
||||
server, err := setupX(uid, gid, groups, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("X setup: %w", err)
|
||||
}
|
||||
|
||||
args := shellCmd(cfg.SystemShell, loginFlag, cfg.X11.SetupPath+" "+env.XinitrcPath)
|
||||
client := privCmd(args, uid, gid, groups)
|
||||
|
||||
logF := openLog(cfg.ClientLogPath)
|
||||
if logF != nil {
|
||||
lw := &limitWriter{w: logF, max: logLimit}
|
||||
client.Stdout = lw
|
||||
client.Stderr = lw
|
||||
}
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
return nil, fmt.Errorf("X11 client: %w", err)
|
||||
}
|
||||
return &SpawnedEnv{Kind: "x11", Server: server, Client: client, logFd: logF}, nil
|
||||
}
|
||||
|
||||
func spawnWayland(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config, loginFlag string) (*SpawnedEnv, error) {
|
||||
args := shellCmd(cfg.SystemShell, loginFlag, env.ScriptPath)
|
||||
client := privCmd(args, uid, gid, groups)
|
||||
|
||||
logF := openLog(cfg.ClientLogPath)
|
||||
if logF != nil {
|
||||
lw := &limitWriter{w: logF, max: logLimit}
|
||||
client.Stdout = lw
|
||||
client.Stderr = lw
|
||||
}
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
return nil, fmt.Errorf("Wayland: %w", err)
|
||||
}
|
||||
return &SpawnedEnv{Kind: "wayland", Client: client, logFd: logF}, nil
|
||||
}
|
||||
|
||||
func spawnTTY(env *PostLoginEnv, uid, gid uint32, groups []uint32, cfg *config.Config) (*SpawnedEnv, error) {
|
||||
shell := userShell(int(uid))
|
||||
_ = env
|
||||
_ = cfg
|
||||
|
||||
client := privCmd([]string{shell}, uid, gid, groups)
|
||||
client.Stdin = os.Stdin
|
||||
client.Stdout = os.Stdout
|
||||
client.Stderr = os.Stderr
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
return nil, fmt.Errorf("TTY: %w", err)
|
||||
}
|
||||
return &SpawnedEnv{Kind: "tty", Client: client}, nil
|
||||
}
|
||||
|
||||
func (s *SpawnedEnv) Wait() error {
|
||||
defer func() {
|
||||
if s.logFd != nil {
|
||||
s.logFd.Close()
|
||||
}
|
||||
}()
|
||||
if s.Kind == "x11" && s.Server != nil {
|
||||
defer func() {
|
||||
s.Server.Process.Signal(syscall.SIGTERM)
|
||||
s.Server.Wait()
|
||||
}()
|
||||
}
|
||||
if s.Client != nil {
|
||||
return s.Client.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SpawnedEnv) PID() int {
|
||||
if s.Client != nil && s.Client.Process != nil {
|
||||
return s.Client.Process.Pid
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func shellCmd(shell, loginFlag, command string) []string {
|
||||
a := []string{shell}
|
||||
if loginFlag != "" {
|
||||
a = append(a, loginFlag)
|
||||
}
|
||||
return append(a, "-c", command)
|
||||
}
|
||||
|
||||
func privCmd(args []string, uid, gid uint32, groups []uint32) *exec.Cmd {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{
|
||||
Uid: uid,
|
||||
Gid: gid,
|
||||
Groups: groups,
|
||||
},
|
||||
Setsid: true,
|
||||
Setctty: false,
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func openLog(path string) *os.File {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func userShell(uid int) string {
|
||||
data, err := os.ReadFile("/etc/passwd")
|
||||
if err != nil {
|
||||
return "/bin/sh"
|
||||
}
|
||||
uidStr := strconv.Itoa(uid)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) >= 7 && parts[2] == uidStr && parts[6] != "" {
|
||||
sh := parts[6]
|
||||
if sh != "/sbin/nologin" && sh != "/usr/sbin/nologin" && sh != "/bin/false" {
|
||||
return sh
|
||||
}
|
||||
}
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
133
src/session/scan.go
Normal file
133
src/session/scan.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// session — environment scanning from xsessions, wayland-sessions,
|
||||
// and custom script directories. Includes desktop entry parser.
|
||||
//
|
||||
// Mirrors vendor/src/post_login/mod.rs:get_envs and parse_desktop_entry.
|
||||
package session
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DesktopEntry struct {
|
||||
Name string
|
||||
Exec string
|
||||
Hidden bool
|
||||
NoDisplay bool
|
||||
}
|
||||
|
||||
func parseDesktopEntry(path string) (*DesktopEntry, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
e := &DesktopEntry{}
|
||||
inSection := false
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
if line[0] == '[' && line[len(line)-1] == ']' {
|
||||
inSection = line == "[Desktop Entry]"
|
||||
continue
|
||||
}
|
||||
if !inSection {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
k, v := line[:eq], line[eq+1:]
|
||||
switch k {
|
||||
case "Name":
|
||||
e.Name = v
|
||||
case "Exec":
|
||||
e.Exec = v
|
||||
case "Hidden":
|
||||
e.Hidden = strings.EqualFold(v, "true")
|
||||
case "NoDisplay":
|
||||
e.NoDisplay = strings.EqualFold(v, "true")
|
||||
}
|
||||
}
|
||||
if sc.Err() != nil {
|
||||
return nil, sc.Err()
|
||||
}
|
||||
if e.Exec == "" {
|
||||
return nil, fmt.Errorf("no Exec in %s", path)
|
||||
}
|
||||
if e.Name == "" {
|
||||
e.Name = e.Exec
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func ScanEnvs(xSessions, wlSessions, xScripts, wlScripts string, includeTTY bool) []PostLoginEnv {
|
||||
var envs []PostLoginEnv
|
||||
|
||||
scanDesktopDir(xSessions, "x11", &envs)
|
||||
scanDesktopDir(wlSessions, "wayland", &envs)
|
||||
scanScriptDir(xScripts, "x11", &envs)
|
||||
scanScriptDir(wlScripts, "wayland", &envs)
|
||||
|
||||
if len(envs) == 0 || includeTTY {
|
||||
envs = append(envs, PostLoginEnv{Kind: "tty", Title: "TTYSHELL"})
|
||||
}
|
||||
return envs
|
||||
}
|
||||
|
||||
func scanDesktopDir(dir, kind string, envs *[]PostLoginEnv) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".desktop" {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
de, err := parseDesktopEntry(path)
|
||||
if err != nil || de.Hidden || de.NoDisplay {
|
||||
continue
|
||||
}
|
||||
*envs = append(*envs, PostLoginEnv{
|
||||
Kind: kind,
|
||||
Title: de.Name,
|
||||
XinitrcPath: de.Exec,
|
||||
ScriptPath: de.Exec,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func scanScriptDir(dir, kind string, envs *[]PostLoginEnv) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.Mode()&0111 == 0 {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
*envs = append(*envs, PostLoginEnv{
|
||||
Kind: kind,
|
||||
Title: e.Name(),
|
||||
XinitrcPath: path,
|
||||
ScriptPath: path,
|
||||
})
|
||||
}
|
||||
}
|
||||
4
src/session/wayland.go
Normal file
4
src/session/wayland.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package session
|
||||
|
||||
// Wayland session spawning is handled in exec.go/spawnWayland.
|
||||
// This file exists for future Wayland-specific compositor logic.
|
||||
103
src/session/x11.go
Normal file
103
src/session/x11.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// session — X11 server setup.
|
||||
//
|
||||
// Mirrors vendor/src/post_login/x.rs:setup_x.
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"latchd/config"
|
||||
)
|
||||
|
||||
func setupX(uid, gid uint32, groups []uint32, cfg *config.Config) (*exec.Cmd, error) {
|
||||
display := os.Getenv("DISPLAY")
|
||||
vtnr := os.Getenv("XDG_VTNR")
|
||||
home := os.Getenv("HOME")
|
||||
if display == "" || vtnr == "" || home == "" {
|
||||
return nil, fmt.Errorf("missing DISPLAY/XDG_VTNR/HOME env vars")
|
||||
}
|
||||
|
||||
xauthPath := filepath.Join(home, ".Xauthority")
|
||||
os.Remove(xauthPath)
|
||||
|
||||
cookie, err := mCookie()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xauthCmd := exec.Command(cfg.X11.XauthPath, "add", display, ".", cookie)
|
||||
xauthCmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{Uid: uid, Gid: gid, Groups: groups},
|
||||
}
|
||||
if out, err := xauthCmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("xauth: %w — %s", err, string(out))
|
||||
}
|
||||
os.Setenv("XAUTHORITY", xauthPath)
|
||||
|
||||
dd := vtnr
|
||||
if len(dd) == 1 {
|
||||
dd = "0" + dd
|
||||
}
|
||||
|
||||
// Ignore SIGUSR1 so Xorg sends it when ready.
|
||||
signal.Ignore(syscall.SIGUSR1)
|
||||
|
||||
cmd := exec.Command(cfg.SystemShell, "-c",
|
||||
fmt.Sprintf("%s %s vt%s", cfg.X11.ServerPath, display, dd))
|
||||
if logF := openLog(cfg.X11.ServerLogPath); logF != nil {
|
||||
lw := &limitWriter{w: logF, max: logLimit}
|
||||
cmd.Stdout = lw
|
||||
cmd.Stderr = lw
|
||||
defer logF.Close()
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
signal.Reset(syscall.SIGUSR1)
|
||||
return nil, fmt.Errorf("X server: %w", err)
|
||||
}
|
||||
|
||||
signal.Reset(syscall.SIGUSR1)
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGUSR1)
|
||||
defer signal.Stop(ch)
|
||||
|
||||
timeout := time.Duration(cfg.X11.ServerTimeoutSecs) * time.Second
|
||||
deadline := time.After(timeout)
|
||||
tick := time.NewTicker(100 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
return cmd, nil
|
||||
case <-deadline:
|
||||
if cfg.X11.ServerTimeoutSecs == 0 {
|
||||
continue
|
||||
}
|
||||
cmd.Process.Signal(syscall.SIGTERM)
|
||||
cmd.Wait()
|
||||
return nil, fmt.Errorf("X server timed out after %ds", cfg.X11.ServerTimeoutSecs)
|
||||
case <-tick.C:
|
||||
var ws syscall.WaitStatus
|
||||
if pid, err := syscall.Wait4(cmd.Process.Pid, &ws, syscall.WNOHANG, nil); err == nil && pid == cmd.Process.Pid {
|
||||
return nil, fmt.Errorf("X server exited with status %d", ws.ExitStatus())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mCookie() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
129
src/tui/bg/automata.go
Normal file
129
src/tui/bg/automata.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// bg — cellular automata background for the TUI.
|
||||
//
|
||||
// Conway's Game of Life rendered at 30 Hz using Unicode half-block
|
||||
// characters (▀) to double the effective vertical resolution.
|
||||
// The grid is double-buffered for zero-allocation ticking, and
|
||||
// render output is built in a pre-allocated strings.Builder so the
|
||||
|
||||
package bg
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Bright orange (accent) for alive cells, dark grey for dead.
|
||||
const (
|
||||
fgAlive = "\x1b[38;2;255;127;0m"
|
||||
fgDead = "\x1b[38;2;30;30;30m"
|
||||
bgAlive = "\x1b[48;2;255;127;0m"
|
||||
bgDead = "\x1b[48;2;30;30;30m"
|
||||
reset = "\x1b[0m"
|
||||
block = "▀"
|
||||
)
|
||||
|
||||
type Automata struct {
|
||||
w, h int
|
||||
a, b []uint8
|
||||
useA bool
|
||||
buf strings.Builder
|
||||
}
|
||||
|
||||
func NewAutomata(w, h int) *Automata {
|
||||
a := &Automata{useA: true}
|
||||
a.Resize(w, h)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Automata) Resize(w, h int) {
|
||||
logH := h * 2
|
||||
if a.w == w && a.h == logH {
|
||||
return
|
||||
}
|
||||
a.w = w
|
||||
a.h = logH
|
||||
sz := w * logH
|
||||
a.a = make([]uint8, sz)
|
||||
a.b = make([]uint8, sz)
|
||||
for i := 0; i < sz; i++ {
|
||||
if rand.Float32() > 0.85 {
|
||||
a.a[i] = 1
|
||||
}
|
||||
}
|
||||
a.buf.Grow(w * h * 40)
|
||||
}
|
||||
|
||||
func (a *Automata) Tick() {
|
||||
src, dst := a.a, a.b
|
||||
if !a.useA {
|
||||
src, dst = a.b, a.a
|
||||
}
|
||||
w, h := a.w, a.h
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
i := y*w + x
|
||||
alive := src[i]
|
||||
n := a.count(src, x, y, w, h)
|
||||
if alive == 1 && (n == 2 || n == 3) {
|
||||
dst[i] = 1
|
||||
} else if alive == 0 && n == 3 {
|
||||
dst[i] = 1
|
||||
} else {
|
||||
dst[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
a.useA = !a.useA
|
||||
}
|
||||
|
||||
func (a *Automata) count(g []uint8, x, y, w, h int) uint8 {
|
||||
var c uint8
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
nx, ny := x+dx, y+dy
|
||||
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
||||
c += g[ny*w+nx]
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Frame returns the current generation as a single string suitable for
|
||||
// writing to the alternate screen.
|
||||
func (a *Automata) Frame() string {
|
||||
a.buf.Reset()
|
||||
src := a.a
|
||||
if !a.useA {
|
||||
src = a.b
|
||||
}
|
||||
w, h := a.w, a.h
|
||||
for y := 0; y < h; y += 2 {
|
||||
for x := range w {
|
||||
top := src[y*w+x]
|
||||
bot := uint8(0)
|
||||
if y+1 < h {
|
||||
bot = src[(y+1)*w+x]
|
||||
}
|
||||
if top == 1 {
|
||||
a.buf.WriteString(fgAlive)
|
||||
} else {
|
||||
a.buf.WriteString(fgDead)
|
||||
}
|
||||
if bot == 1 {
|
||||
a.buf.WriteString(bgAlive)
|
||||
} else {
|
||||
a.buf.WriteString(bgDead)
|
||||
}
|
||||
a.buf.WriteString(block)
|
||||
}
|
||||
a.buf.WriteString(reset)
|
||||
if y < h-2 {
|
||||
a.buf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return a.buf.String()
|
||||
}
|
||||
Reference in New Issue
Block a user