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:
2026-10-04 14:51:25 +00:00
11 changed files with 607 additions and 45 deletions
+8
View File
@@ -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")
+8 -37
View File
@@ -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"
}
]
}
+47
View File
@@ -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() },
},
}
}
+5
View File
@@ -0,0 +1,5 @@
module triggerhappy
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
+73
View File
@@ -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"
]
}
]
}
}