From afce6b03f4f6bb099433166038e43de41493594c Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 16:50:57 +0200 Subject: [PATCH] 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. --- modules/asus-zephyrus-g14/README.md | 8 + .../cmd/zephyrus/keys_test.go | 2 +- .../cmd/zephyrus/manifest_test.go | 25 +- modules/asus-zephyrus-g14/module.json | 45 +-- modules/triggerhappy/README.md | 47 +++ .../cmd/triggerhappy-tools/machine.go | 288 ++++++++++++++++++ .../cmd/triggerhappy-tools/machine_test.go | 98 ++++++ .../cmd/triggerhappy-tools/main.go | 59 ++++ modules/triggerhappy/go.mod | 5 + modules/triggerhappy/go.sum | 2 + modules/triggerhappy/module.json | 73 +++++ 11 files changed, 607 insertions(+), 45 deletions(-) create mode 100644 modules/triggerhappy/README.md create mode 100644 modules/triggerhappy/cmd/triggerhappy-tools/machine.go create mode 100644 modules/triggerhappy/cmd/triggerhappy-tools/machine_test.go create mode 100644 modules/triggerhappy/cmd/triggerhappy-tools/main.go create mode 100644 modules/triggerhappy/go.mod create mode 100644 modules/triggerhappy/go.sum create mode 100644 modules/triggerhappy/module.json diff --git a/modules/asus-zephyrus-g14/README.md b/modules/asus-zephyrus-g14/README.md index 94c55a5..108e790 100644 --- a/modules/asus-zephyrus-g14/README.md +++ b/modules/asus-zephyrus-g14/README.md @@ -360,3 +360,11 @@ The tests cover: shipped executable script, every script passes `bash -n`; - `zephyrus_keys` over the module's own triggers and i3 lines, and a key in two trigger files; - the checks for `as-user`, triggerhappy's account and the resume unit. + +## The vendor keys are a contribution (changed 2026-10-04, novox/hq ADR 0212) + +The trigger file and triggerhappy's service drop-in are no longer this module's. The `triggerhappy` +module holds `node-hotkeys`, owns the daemon, and reads only the mesh's trigger file. This module +contributes its eight trigger lines (media, panel brightness, touchpad) to that seat, so it depends +on a hotkey holder being assigned beside it. Its keys still run this module's own scripts. +`zephyrus_keys` reads the trigger directory as before. diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go index d073365..68a71d9 100644 --- a/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go @@ -30,7 +30,7 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) { content[r["id"].(string)] = c } } - put(t, f.root, TriggerDir+"/asus-g14.conf", content["vendor-keys"]) + put(t, f.root, TriggerDir+"/mesh.conf", "# asus-zephyrus-g14\n"+m.triggers()) home := "/home/operator" t.Setenv("MESH_OPERATOR_HOME", home) put(t, f.root, home+"/"+I3Fragments[0], content["i3-vendor-keys"]) diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go index ea7490a..7e986c6 100644 --- a/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go @@ -15,6 +15,22 @@ type manifest struct { Tools []string `json:"tools"` Emits []string `json:"emits"` Resources []map[string]any `json:"resources"` + // Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys. + Contributions []struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Content string `json:"content"` + } `json:"contributions"` +} + +// triggers is the module's contribution to node-hotkeys: its vendor keys. +func (m manifest) triggers() string { + for _, c := range m.Contributions { + if c.Seat == "node-hotkeys" && c.Kind == "trigger" { + return c.Content + } + } + return "" } func readManifest(t *testing.T) manifest { @@ -76,14 +92,9 @@ func TestTheManifestNamesNoMachineAndItsTriggersExist(t *testing.T) { // Every trigger runs a script the module ships, and every script parses. func TestTheVendorKeysRunTheModulesOwnScriptsAndTheyParse(t *testing.T) { m := readManifest(t) - var triggers string - for _, r := range m.Resources { - if r["id"] == "vendor-keys" { - triggers = r["content"].(string) - } - } + triggers := m.triggers() if triggers == "" { - t.Fatal("no vendor-keys resource") + t.Fatal("no trigger contribution to node-hotkeys") } for _, line := range strings.Split(triggers, "\n") { f := strings.Split(line, "\t") diff --git a/modules/asus-zephyrus-g14/module.json b/modules/asus-zephyrus-g14/module.json index 2751844..6a6a98c 100644 --- a/modules/asus-zephyrus-g14/module.json +++ b/modules/asus-zephyrus-g14/module.json @@ -156,42 +156,6 @@ "backlight-rule" ] }, - { - "id": "triggerhappy-drop-ins", - "type": "directory", - "path": "/etc/systemd/system/triggerhappy.service.d", - "mode": "0755" - }, - { - "id": "triggerhappy-as-account", - "type": "file", - "path": "/etc/systemd/system/triggerhappy.service.d/asus-zephyrus-g14.conf", - "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# triggerhappy runs the vendor-key triggers as the operator's account, not as root: it opens the input\n# devices first and then drops to the account with its groups (input, video), so the triggers reach the\n# account's own session bus and the panel through the video group, with no su and no hard-coded user.\n[Service]\nExecStart=\nExecStart=/usr/bin/thd --triggers /etc/triggerhappy/triggers.d/ --socket /run/thd.socket --user ${machine:account} --deviceglob /dev/input/event*\n" - }, - { - "id": "vendor-keys", - "type": "file", - "path": "/etc/triggerhappy/triggers.d/asus-g14.conf", - "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ROG Zephyrus G14's vendor keys, which reach no X client. Run as the operator's account (see the\n# module's drop-in on triggerhappy.service).\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" - }, - { - "id": "triggerhappy", - "type": "service", - "unit": "triggerhappy.service", - "state": "running", - "boot": "enabled", - "restart-on": [ - "triggerhappy-as-account", - "vendor-keys", - "scripts", - "touchpad-on-resume", - "touchpad-after-suspend", - "touchpad-after-hibernate", - "touchpad-after-suspend-then-hibernate" - ] - }, { "id": "upower-package", "type": "package", @@ -297,5 +261,12 @@ "from": "files" } ] - } + }, + "contributions": [ + { + "seat": "node-hotkeys", + "kind": "trigger", + "content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" + } + ] } diff --git a/modules/triggerhappy/README.md b/modules/triggerhappy/README.md new file mode 100644 index 0000000..a648fb1 --- /dev/null +++ b/modules/triggerhappy/README.md @@ -0,0 +1,47 @@ +# triggerhappy + +The machine's hotkeys (novox/hq ADR 0212). triggerhappy is the daemon that sees the keys the window +manager never does, such as a laptop's vendor keys. This module holds `node-hotkeys` and owns the +daemon's configuration and service. The keys themselves belong to the modules they are for. + +## What it owns + +| | | +|---|---| +| `/etc/triggerhappy/triggers.d/mesh.conf` | every module's trigger lines, placed with `${contribution:node-hotkeys:trigger}` in module order, each module's under a `# ` line | +| `triggerhappy.service.d/mesh.conf` | the daemon reads only the mesh's file, and runs every trigger as the operator's account (`--user`), after opening the devices as root | +| `triggerhappy.service` | running and enabled; restarted when either file changes | + +## How a module adds a key + +A module declares a `contributions` entry with `seat` `node-hotkeys`, `kind` `trigger`, and as +`content` one or more trigger lines in triggerhappy's own grammar: the key, its state (`1` pressed, +`2` held, `0` released), and the command. The command runs as the operator's account. It needs no +`su` and should name the contributing module's own scripts. The contribution depends on this seat +(ADR 0210), so a module with keys is refused on a machine without a hotkey holder. + +To find a key's name, press it while `triggerhappy_dump` listens. + +## Tools + +| tool | | +|---|---| +| `triggerhappy_keys` | every trigger in force: key, state, command, file and contributing module; warns about a key bound twice | +| `triggerhappy_devices` | the input devices, by name and event handler | +| `triggerhappy_dump` | listen for a few seconds and say which keys arrived (needs `sudo -n`) | +| `triggerhappy_check` | running, as the account, only the mesh's file, no duplicates | + +## Installed as found + +triggerhappy is not in the distribution's repositories, so the host cannot install it, and the module +declares no package. It depends on the machine having it: the service resource refuses loudly, by +name, on a machine without it. How software from the user repository reaches the mesh, as a pinned +archive (ADR 0205) or through a repository the mesh serves, is research 027's first question. + +## Migration + +On the laptop, the model's module wrote `asus-g14.conf` and its own drop-in. Both move here: the +model's module now contributes its keys. When it is pushed, the host gives back the original +`asus-g14.conf` it kept, which still calls the predecessor's `as-user` scripts. Delete that file +afterwards. The daemon reads only `mesh.conf` from then on, so the file would not fire, but +`triggerhappy_check` flags any file besides the mesh's. diff --git a/modules/triggerhappy/cmd/triggerhappy-tools/machine.go b/modules/triggerhappy/cmd/triggerhappy-tools/machine.go new file mode 100644 index 0000000..e098b4b --- /dev/null +++ b/modules/triggerhappy/cmd/triggerhappy-tools/machine.go @@ -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 "# " +// 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 +} diff --git a/modules/triggerhappy/cmd/triggerhappy-tools/machine_test.go b/modules/triggerhappy/cmd/triggerhappy-tools/machine_test.go new file mode 100644 index 0000000..e2d82f1 --- /dev/null +++ b/modules/triggerhappy/cmd/triggerhappy-tools/machine_test.go @@ -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) + } +} diff --git a/modules/triggerhappy/cmd/triggerhappy-tools/main.go b/modules/triggerhappy/cmd/triggerhappy-tools/main.go new file mode 100644 index 0000000..1df0048 --- /dev/null +++ b/modules/triggerhappy/cmd/triggerhappy-tools/main.go @@ -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() }, + }, + } +} diff --git a/modules/triggerhappy/go.mod b/modules/triggerhappy/go.mod new file mode 100644 index 0000000..4d2eab7 --- /dev/null +++ b/modules/triggerhappy/go.mod @@ -0,0 +1,5 @@ +module triggerhappy + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/triggerhappy/go.sum b/modules/triggerhappy/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/triggerhappy/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/triggerhappy/module.json b/modules/triggerhappy/module.json new file mode 100644 index 0000000..5219880 --- /dev/null +++ b/modules/triggerhappy/module.json @@ -0,0 +1,73 @@ +{ + "module": "triggerhappy", + "version": "1", + "capabilities": [ + "service-manager" + ], + "claims": [ + { + "name": "node-hotkeys", + "scope": "node" + } + ], + "tools": [ + "triggerhappy_keys", + "triggerhappy_devices", + "triggerhappy_dump", + "triggerhappy_check" + ], + "resources": [ + { + "id": "drop-ins", + "type": "directory", + "path": "/etc/systemd/system/triggerhappy.service.d", + "mode": "0755" + }, + { + "id": "as-account", + "type": "file", + "path": "/etc/systemd/system/triggerhappy.service.d/mesh.conf", + "mode": "0644", + "content": "# Written by the mesh (module triggerhappy, novox/hq ADR 0212). Replaced on every push.\n#\n# The daemon opens the input devices as root, then drops to the operator's account with its groups,\n# so every trigger runs as the account: it reaches the account's session bus and display, with no su\n# and no hard-coded user. Only the mesh's trigger file is read.\n[Service]\nExecStart=\nExecStart=/usr/bin/thd --triggers /etc/triggerhappy/triggers.d/mesh.conf --socket /run/thd.socket --user ${machine:account} --deviceglob /dev/input/event*\n" + }, + { + "id": "triggers-dir", + "type": "directory", + "path": "/etc/triggerhappy/triggers.d", + "mode": "0755" + }, + { + "id": "triggers", + "type": "file", + "path": "/etc/triggerhappy/triggers.d/mesh.conf", + "mode": "0644", + "content": "# The machine's hotkeys, written by the mesh (module triggerhappy, novox/hq ADR 0212). Replaced on\n# every push: a key is added by the module it belongs to, as a contribution to node-hotkeys.\n# A line is: the key, its state (1 pressed, 2 held, 0 released), the command.\n${contribution:node-hotkeys:trigger}" + }, + { + "id": "service", + "type": "service", + "unit": "triggerhappy.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "as-account", + "triggers" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/triggerhappy-tools", + "binary": "triggerhappy-tools", + "loads": [ + "triggerhappy-tools" + ] + } + ] + } +}