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.
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeRoot(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for p, c := range files {
|
||||
full := filepath.Join(root, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(c), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const meshConf = "# The machine's hotkeys, written by the mesh (module triggerhappy).\n" +
|
||||
"# asus-zephyrus-g14\nKEY_PROG1\t1\t/usr/local/lib/x/media play-pause\n# a note of several words\nKEY_F21\t1\t/usr/local/lib/x/touchpad reset\n" +
|
||||
"# other\nKEY_MUTE\t1\tmute\n"
|
||||
|
||||
func TestTriggersAreAttributedToTheModuleThatContributedThem(t *testing.T) {
|
||||
m := &Machine{Root: fakeRoot(t, map[string]string{TriggerDir + "/mesh.conf": meshConf})}
|
||||
ts, err := m.Triggers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, tr := range ts {
|
||||
got = append(got, tr.Key+"="+tr.Module+"/"+tr.When)
|
||||
}
|
||||
want := []string{"KEY_PROG1=asus-zephyrus-g14/pressed", "KEY_F21=asus-zephyrus-g14/pressed", "KEY_MUTE=other/pressed"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyBoundTwiceIsWarnedAboutAndFailsTheCheck(t *testing.T) {
|
||||
m := &Machine{Root: fakeRoot(t, map[string]string{
|
||||
TriggerDir + "/mesh.conf": meshConf,
|
||||
TriggerDir + "/asus-g14.conf": "KEY_PROG1\t1\t/home/op/scripts/as-user media-control play-pause\n",
|
||||
}), Timeout: time.Second, Run: func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if strings.Join(args, " ") == "is-active triggerhappy.service" {
|
||||
return "active\n", nil
|
||||
}
|
||||
return "/usr/bin/thd --user op", nil
|
||||
}}
|
||||
k, err := m.Keys()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(k.Warnings) != 1 || !strings.Contains(k.Warnings[0], "KEY_PROG1 pressed is bound 2 times") {
|
||||
t.Fatalf("%v", k.Warnings)
|
||||
}
|
||||
c, err := m.Check()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c["failing"] != 2 {
|
||||
t.Fatalf("the predecessor's file and the duplicate should fail: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADumpSaysEachKeyAndStateOnce(t *testing.T) {
|
||||
out := "EV_KEY\tKEY_PROG1\t1\t/dev/input/event5\n# KEY_PROG1\t1\tcommand\n" +
|
||||
"EV_KEY\tKEY_PROG1\t0\t/dev/input/event5\n# KEY_PROG1\t0\tcommand\n# KEY_PROG1\t1\tcommand\n"
|
||||
m := &Machine{Timeout: time.Second, Run: func(context.Context, string, ...string) (string, error) {
|
||||
return out, context.DeadlineExceeded
|
||||
}}
|
||||
a, err := m.Dump(2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"KEY_PROG1 pressed", "KEY_PROG1 released"}; !reflect.DeepEqual(a.Events, want) {
|
||||
t.Fatalf("%v, want %v", a.Events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicesWithAnEventHandlerAreListed(t *testing.T) {
|
||||
m := &Machine{Root: fakeRoot(t, map[string]string{"/proc/bus/input/devices": "N: Name=\"Asus WMI hotkeys\"\nH: Handlers=kbd event5\n\nN: Name=\"Nothing\"\nH: Handlers=mouse0\n\n"})}
|
||||
d, err := m.Devices()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d) != 1 || d[0].Name != "Asus WMI hotkeys" {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// triggerhappy's Go tools bundle (novox/hq ADR 0212): the holder of node-hotkeys. The daemon sees
|
||||
// the keys the window manager never does — a laptop's vendor keys — and runs the trigger lines every
|
||||
// module on the machine contributed. Served by the node's runtime as the operator account.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(NewMachine())); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func tools(m *Machine) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "triggerhappy_keys",
|
||||
Description: "Every hotkey trigger in force on this machine: the key, when it fires (pressed, held, " +
|
||||
"released), the command it runs, the file it is in and the module that contributed it. " +
|
||||
"Warns about a key bound twice for the same state, which fires both commands.",
|
||||
Run: func(map[string]any) (any, error) { return m.Keys() },
|
||||
},
|
||||
{
|
||||
Name: "triggerhappy_devices",
|
||||
Description: "The input devices the hotkey daemon reads: each event device's name and what it reports.",
|
||||
Run: func(map[string]any) (any, error) { return m.Devices() },
|
||||
},
|
||||
{
|
||||
Name: "triggerhappy_dump",
|
||||
Description: "Listen for key events for a few seconds and say which key names arrive, so a key's name " +
|
||||
"can be found for a new trigger: press the key while this runs. Needs sudo -n for the devices.",
|
||||
Input: map[string]any{
|
||||
"seconds": map[string]any{"type": "integer", "description": "how long to listen (default 5, at most 20)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
s := 5
|
||||
if v, ok := args["seconds"].(float64); ok {
|
||||
s = int(v)
|
||||
}
|
||||
if s < 1 || s > 20 {
|
||||
return nil, fmt.Errorf("seconds is 1 to 20")
|
||||
}
|
||||
return m.Dump(s)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "triggerhappy_check",
|
||||
Description: "Whether the hotkey daemon is as the mesh declares it: running, as the operator's account, " +
|
||||
"reading only the mesh's trigger file, with no key bound twice.",
|
||||
Run: func(map[string]any) (any, error) { return m.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user