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:
jochen
2026-10-04 16:50:57 +02:00
parent 2d613ae274
commit afce6b03f4
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"
]
}
]
}
}