Merge pull request 'triggerhappy: the machine's hotkeys as a module holding node-hotkeys (hq ADR 0212)' (#279) from feat/triggerhappy-module into main
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 `# <module>` 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.
|
||||
@@ -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() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module triggerhappy
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user