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.
289 lines
7.9 KiB
Go
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
|
|
}
|