Files
mesh-catalog/modules/triggerhappy/cmd/triggerhappy-tools/machine.go
T
jochen afce6b03f4 triggerhappy: the machine's hotkeys as a module holding node-hotkeys (hq ADR 0212)
The laptop's model module owned triggerhappy's trigger file and service, although the daemon is a
general piece others have keys for. triggerhappy now owns the daemon, reads only the mesh's file,
runs every trigger as the account, and the model module contributes its vendor keys.
2026-10-04 16:50:57 +02:00

289 lines
7.9 KiB
Go

package main
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// TriggerDir is the daemon's directory of trigger files, and MeshFile the one the mesh writes.
const (
TriggerDir = "/etc/triggerhappy/triggers.d"
MeshFile = "mesh.conf"
outputCap = 64 << 10
)
// Runner runs a command and answers its combined output; injected so the tools are tested without a
// machine.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Machine is what the tools read: the trigger directory, the input devices list and commands.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Run Runner
Timeout time.Duration
}
func NewMachine() *Machine {
return &Machine{Run: run, Timeout: 20 * time.Second}
}
func run(ctx context.Context, name string, args ...string) (string, error) {
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
if len(out) > outputCap {
out = append(out[:outputCap], []byte("\n[cut]")...)
}
return string(out), err
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
func (m *Machine) cmd(args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), m.Timeout)
defer cancel()
return m.Run(ctx, args[0], args[1:]...)
}
// Trigger is one line of a trigger file.
type Trigger struct {
Key string `json:"key"`
When string `json:"when"`
Command string `json:"runs"`
File string `json:"file"`
Line int `json:"line"`
Module string `json:"module,omitempty"`
}
func when(v string) string {
switch v {
case "0":
return "released"
case "1":
return "pressed"
case "2":
return "held (repeat)"
}
return v
}
// Triggers reads every trigger file. In the mesh's file a comment line that is exactly "# <module>"
// opens that module's contribution (novox/hq ADR 0212 §3), so each trigger is attributed.
func (m *Machine) Triggers() ([]Trigger, error) {
files, err := filepath.Glob(m.path(filepath.Join(TriggerDir, "*.conf")))
if err != nil {
return nil, err
}
sort.Strings(files)
var out []Trigger
for _, f := range files {
h, err := os.Open(f)
if err != nil {
return nil, err
}
module := ""
s := bufio.NewScanner(h)
n := 0
for s.Scan() {
n++
line := strings.TrimSpace(s.Text())
if strings.HasPrefix(line, "#") {
if name := strings.TrimSpace(strings.TrimPrefix(line, "#")); isModuleName(name) {
module = name
}
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
t := Trigger{Key: fields[0], When: when(fields[1]), File: strings.TrimPrefix(f, m.Root), Line: n,
Command: strings.Join(fields[2:], " ")}
if filepath.Base(f) == MeshFile {
t.Module = module
}
out = append(out, t)
}
h.Close()
}
return out, nil
}
// isModuleName is a catalogue module's name: lower case, digits and dashes, one word.
func isModuleName(s string) bool {
if s == "" || strings.ContainsAny(s, " \t") {
return false
}
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
return true
}
// KeysAnswer is triggerhappy_keys' answer.
type KeysAnswer struct {
Keys []Trigger `json:"keys"`
Warnings []string `json:"warnings"`
}
func (m *Machine) Keys() (KeysAnswer, error) {
ts, err := m.Triggers()
if err != nil {
return KeysAnswer{}, err
}
return KeysAnswer{Keys: ts, Warnings: duplicates(ts)}, nil
}
// duplicates names every key and state bound more than once: the daemon runs every match.
func duplicates(ts []Trigger) []string {
seen := map[string][]Trigger{}
var order []string
for _, t := range ts {
k := t.Key + " " + t.When
if _, ok := seen[k]; !ok {
order = append(order, k)
}
seen[k] = append(seen[k], t)
}
warnings := []string{}
for _, k := range order {
if len(seen[k]) < 2 {
continue
}
var where []string
for _, t := range seen[k] {
where = append(where, fmt.Sprintf("%s:%d", t.File, t.Line))
}
warnings = append(warnings, fmt.Sprintf("%s is bound %d times, and fires every one: %s", k, len(seen[k]),
strings.Join(where, ", ")))
}
return warnings
}
// Device is one input device the kernel lists.
type Device struct {
Name string `json:"name"`
Handlers string `json:"handlers"`
}
// Devices reads /proc/bus/input/devices: each device's name and its event handler.
func (m *Machine) Devices() ([]Device, error) {
raw, err := os.ReadFile(m.path("/proc/bus/input/devices"))
if err != nil {
return nil, err
}
var out []Device
var d Device
for _, line := range strings.Split(string(raw), "\n") {
switch {
case strings.HasPrefix(line, "N: Name="):
d.Name = strings.Trim(strings.TrimPrefix(line, "N: Name="), `"`)
case strings.HasPrefix(line, "H: Handlers="):
d.Handlers = strings.TrimSpace(strings.TrimPrefix(line, "H: Handlers="))
case strings.TrimSpace(line) == "" && d.Name != "":
if strings.Contains(d.Handlers, "event") {
out = append(out, d)
}
d = Device{}
}
}
return out, nil
}
// DumpAnswer is the key names that arrived while listening.
type DumpAnswer struct {
Seconds int `json:"seconds"`
Events []string `json:"events"`
Note string `json:"note,omitempty"`
}
// Dump listens with the daemon's own dump mode, bounded by timeout(1); its exit on the bound is the
// normal end, not a failure.
func (m *Machine) Dump(seconds int) (DumpAnswer, error) {
out, err := m.cmd("sudo", "-n", "timeout", strconv.Itoa(seconds), "thd", "--dump", "--deviceglob", "/dev/input/event*")
a := DumpAnswer{Seconds: seconds, Events: []string{}}
// thd --dump prints each event twice: the raw line (EV_KEY KEY_X 1 /dev/input/eventN) and a
// skeleton trigger line (# KEY_X 1 command). The skeleton is the trigger's own grammar, so it is
// what is read; each key and state once, in the order they came.
seen := map[string]bool{}
for _, line := range strings.Split(out, "\n") {
f := strings.Fields(line)
if len(f) < 3 || f[0] != "#" {
continue
}
key := f[1]
if !(strings.HasPrefix(key, "KEY_") || strings.HasPrefix(key, "BTN_") || strings.HasPrefix(key, "SW_")) {
continue
}
e := key + " " + when(f[2])
if !seen[e] {
seen[e] = true
a.Events = append(a.Events, e)
}
}
if err != nil && len(a.Events) == 0 && !strings.Contains(err.Error(), "exit status 124") {
if strings.Contains(out, "password") {
return a, fmt.Errorf("sudo -n refused: the operator's sudo rule must allow it without a password")
}
a.Note = strings.TrimSpace(out)
}
return a, nil
}
// Check is triggerhappy_check's answer.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
func (m *Machine) Check() (map[string]any, error) {
var checks []Check
add := func(name string, ok bool, detail string) { checks = append(checks, Check{name, ok, detail}) }
state, _ := m.cmd("systemctl", "is-active", "triggerhappy.service")
state = strings.TrimSpace(state)
add("triggerhappy.service", state == "active", state)
start, _ := m.cmd("systemctl", "show", "-p", "ExecStart", "--value", "triggerhappy.service")
asAccount := strings.Contains(start, "--user ")
add("runs as the account", asAccount, fmt.Sprintf("the service passes --user: %v", asAccount))
files, _ := filepath.Glob(m.path(filepath.Join(TriggerDir, "*.conf")))
var others []string
for _, f := range files {
if filepath.Base(f) != MeshFile {
others = append(others, strings.TrimPrefix(f, m.Root))
}
}
add("only the mesh's trigger file", len(others) == 0, func() string {
if len(others) == 0 {
return "only " + MeshFile
}
return "also read: " + strings.Join(others, ", ") + " — a predecessor's file fires its keys too; remove it"
}())
ts, err := m.Triggers()
if err != nil {
return nil, err
}
d := duplicates(ts)
add("no key bound twice", len(d) == 0, strings.Join(d, "; "))
failing := 0
for _, c := range checks {
if !c.OK {
failing++
}
}
return map[string]any{"checks": checks, "failing": failing}, nil
}