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 "# " // 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 }