dunst: the notifier as a module, claiming node-notifier and serving send and history (hq ADR 0208)

Owns one dunstrc (the laptop's, in the interface face, its menu on the seat's
dmenu command) and the dunstrc.d directory for other modules' rules; D-Bus
starts it, so nothing else does. Go tools over the session bus.
This commit is contained in:
jochen
2026-10-04 13:10:58 +02:00
parent fba2d43591
commit 1fcc0ffbe2
13 changed files with 1784 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
# dunst
The notifier as a module (novox/hq ADR 0208, research 026/05).
- Installs `dunst`, and `libnotify` for `notify-send`, the client every program and these tools use.
- Claims the mesh's `node-notifier` seat and serves its verbs `send` and `history`.
- Owns `~/.config/dunst/dunstrc` and the directory `~/.config/dunst/dunstrc.d/`. Another module's
rule is that module's own file in the directory (ADR 0208 §4). dunst reads the directory after
`dunstrc`, so a drop-in outranks it.
- **Starts nothing.** The package registers dunst with D-Bus, which starts it on the first
notification, inside the account's service manager. There is no autostart line, no unit and no
session-start contribution.
- **Requires no display of its own.** dunst speaks both X11 and Wayland and picks the one the session
has, so it serves an X session and a later sway one alike.
## Tools
Every tool goes over the account's session bus. None needs the screen, and each answers clearly when
the account is not logged in.
| tool | does |
|---|---|
| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long; answers its id |
| `node-notifier.history` | what was shown, newest first, with how long ago |
| `dunst_pause` / `dunst_resume` | do not disturb: notifications are held back, not lost |
| `dunst_close_all` | clear the screen; the history keeps them |
| `dunst_rules` | the rules the running notifier holds, and the files they come from |
| `dunst_count` | shown, waiting, in history, and whether paused |
## What it chose, and what it improves
The workstations' files differed: one had the notifications bottom-right, 15 % transparent and with
rounded corners; the other top-right, opaque and square. This module takes the second, because the
rest of the desktop is square and opaque, and the top-right corner sits under the bar that shows the
count. The file keeps only the settings that differ from dunst's defaults.
- **The context menu works.** It called `/usr/bin/dmenu`, installed on neither machine. It now calls
`dmenu`, the seat command of whichever module holds `node-launcher` (`rofi` on the workstations).
- **The face is the interface one,** Inter (research 026/04), instead of a monospace Nerd font.
- `icon_path`, which named two directories of an icon theme that is not installed, is gone. The icon
theme is looked up recursively.
## What it leaves as found
- `~/.config/dunst/dunstrc.d/50-slack.conf`, the Slack rule. It becomes the Slack module's own drop-in
when there is one, and until then it is the operator's file in a directory this module owns.
## Migration (ADR 0182)
- The first push keeps the found `dunstrc` once, then writes the module's.
- **The desktop runs two notification daemons** because its session began before the session bus
fix (research 026/01). That ends at the next login, and nothing here starts a second one.
`dunst_count` after logging in again shows the one daemon's counts.
## Blockers
- `node-notifier` is ADR 0208's seat. Until the controller knows it, `mctl` reads the claim as
unknown.
- `dunst_rules` and the counts ask the running notifier. When none runs, the bus starts one, which
needs a session to draw on.
+97
View File
@@ -0,0 +1,97 @@
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
// The same in every desktop module that carries it.
package main
import (
"fmt"
"math"
"strings"
"time"
)
// text is a string argument, trimmed; required says an empty one is refused.
func text(args map[string]any, key string, required bool) (string, error) {
v, present := args[key]
if !present || v == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s is a string, not %T", key, v)
}
s = strings.TrimSpace(s)
if s == "" && required {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is a whole-number argument within [least, most], or def when absent.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok {
if i, isInt := v.(int); isInt {
f = float64(i)
} else {
return 0, fmt.Errorf("%s is a number, not %T", key, v)
}
}
if f != math.Trunc(f) {
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
}
n := int(f)
if n < least || n > most {
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
}
return n, nil
}
// flag is a boolean argument, or def when absent.
func flag(args map[string]any, key string, def bool) (bool, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false, not %T", key, v)
}
return b, nil
}
// texts is a list-of-strings argument.
func texts(args map[string]any, key string) ([]string, error) {
v, present := args[key]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
if ss, isStrings := v.([]string); isStrings {
return ss, nil
}
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
}
out := make([]string, 0, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
}
out = append(out, s)
}
return out, nil
}
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
n, err := whole(args, key, def, 1, most)
return time.Duration(n) * time.Second, err
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
)
var urgencies = []string{"low", "normal", "critical"}
const busTimeout = 10 * time.Second
// Notification is what node-notifier.send shows.
type Notification struct {
Summary string
Body string
Urgency string
AppName string
Icon string
ExpireMS int
Category string
ReplaceID int
}
func notificationOf(args map[string]any) (Notification, error) {
var n Notification
var err error
if n.Summary, err = text(args, "summary", true); err != nil {
return n, err
}
if n.Body, err = text(args, "body", false); err != nil {
return n, err
}
if n.Urgency, err = text(args, "urgency", false); err != nil {
return n, err
}
if n.Urgency == "" {
n.Urgency = "normal"
}
known := false
for _, u := range urgencies {
known = known || u == n.Urgency
}
if !known {
return n, fmt.Errorf("urgency %q is low, normal or critical", n.Urgency)
}
if n.AppName, err = text(args, "app_name", false); err != nil {
return n, err
}
if n.AppName == "" {
n.AppName = "mesh"
}
if n.Icon, err = text(args, "icon", false); err != nil {
return n, err
}
if n.ExpireMS, err = whole(args, "expire_ms", -1, 0, 24*3600*1000); err != nil {
return n, err
}
if n.Category, err = text(args, "category", false); err != nil {
return n, err
}
n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1)
return n, err
}
// SendResult is what node-notifier.send answers.
type SendResult struct {
ID int `json:"id"`
}
// Send shows a notification through the desktop's notification service, whichever runs it.
func Send(n Notification) (SendResult, error) {
s, err := findBus()
if err != nil {
return SendResult{}, err
}
args := []string{"--print-id", "--urgency=" + n.Urgency, "--app-name=" + n.AppName}
if n.Icon != "" {
args = append(args, "--icon="+n.Icon)
}
if n.ExpireMS >= 0 {
args = append(args, "--expire-time="+strconv.Itoa(n.ExpireMS))
}
if n.Category != "" {
args = append(args, "--category="+n.Category)
}
if n.ReplaceID > 0 {
args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID))
}
// "--" so a title that starts with a dash is a title.
args = append(args, "--", n.Summary)
if n.Body != "" {
args = append(args, n.Body)
}
r, err := s.run(busTimeout, "", "notify-send", args...)
if err != nil {
return SendResult{}, err
}
if r.Code != 0 {
return SendResult{}, fmt.Errorf("notify-send: %s", strings.TrimSpace(r.Stderr))
}
id, err := strconv.Atoi(strings.TrimSpace(r.Stdout))
if err != nil {
return SendResult{}, fmt.Errorf("notify-send answered no id: %q", r.Stdout)
}
return SendResult{ID: id}, nil
}
// dunstctl runs one dunstctl command over the session bus and answers what it printed.
func dunstctl(args ...string) (string, error) {
s, err := findBus()
if err != nil {
return "", err
}
r, err := s.run(busTimeout, "", "dunstctl", args...)
if err != nil {
return "", err
}
if r.Code != 0 {
return "", fmt.Errorf("dunstctl %s: %s", strings.Join(args, " "), strings.TrimSpace(r.Stderr+r.Stdout))
}
return r.Stdout, nil
}
// variantMaps reads busctl's JSON form of an array of dictionaries (aa{sv}), which is how dunstctl
// answers history and rules, into plain maps.
func variantMaps(raw string) ([]map[string]any, error) {
var doc struct {
Type string `json:"type"`
Data [][]map[string]struct {
Data any `json:"data"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return nil, fmt.Errorf("dunstctl's answer is not the bus's JSON: %w", err)
}
if doc.Type != "aa{sv}" {
return nil, fmt.Errorf("dunstctl answered %s, not aa{sv}", doc.Type)
}
out := []map[string]any{}
for _, group := range doc.Data {
for _, entry := range group {
m := map[string]any{}
for k, v := range entry {
m[k] = v.Data
}
out = append(out, m)
}
}
return out, nil
}
// Shown is one notification in the history.
type Shown struct {
ID int `json:"id"`
AppName string `json:"app_name"`
Summary string `json:"summary"`
Body string `json:"body,omitempty"`
Urgency string `json:"urgency"`
Category string `json:"category,omitempty"`
AgeSeconds int64 `json:"age_seconds"`
}
// HistoryResult is what node-notifier.history answers.
type HistoryResult struct {
Total int `json:"total"`
Notifications []Shown `json:"notifications"`
}
// History is dunst's history, newest first.
func History(limit int) (HistoryResult, error) {
raw, err := dunstctl("history")
if err != nil {
return HistoryResult{}, err
}
return parseHistory(raw, monotonicMicros(), limit)
}
func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error) {
entries, err := variantMaps(raw)
if err != nil {
return HistoryResult{}, err
}
out := HistoryResult{Total: len(entries), Notifications: []Shown{}}
type stamped struct {
Shown
at int64
}
var all []stamped
for _, e := range entries {
at := number(e["timestamp"])
all = append(all, stamped{Shown{
ID: int(number(e["id"])), AppName: str(e["appname"]), Summary: str(e["summary"]), Body: str(e["body"]),
Urgency: strings.ToLower(str(e["urgency"])), Category: str(e["category"]),
AgeSeconds: max(0, (nowMicros-at)/1_000_000),
}, at})
}
sort.SliceStable(all, func(i, j int) bool { return all[i].at > all[j].at })
for i, s := range all {
if i == limit {
break
}
out.Notifications = append(out.Notifications, s.Shown)
}
return out, nil
}
func number(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case json.Number:
i, _ := n.Int64()
return i
}
return 0
}
func str(v any) string {
s, _ := v.(string)
return s
}
// monotonicMicros is the clock dunst stamps its notifications with (CLOCK_MONOTONIC, microseconds).
func monotonicMicros() int64 {
var ts syscall.Timespec
const clockMonotonic = 1
if _, _, errno := syscall.Syscall(syscall.SYS_CLOCK_GETTIME, clockMonotonic, uintptr(unsafe.Pointer(&ts)), 0); errno != 0 {
return 0
}
return ts.Sec*1_000_000 + ts.Nsec/1000
}
// PauseResult is what dunst_pause and dunst_resume answer.
type PauseResult struct {
Paused bool `json:"paused"`
Note string `json:"note"`
}
// SetPaused turns do-not-disturb on or off.
func SetPaused(on bool) (PauseResult, error) {
if _, err := dunstctl("set-paused", strconv.FormatBool(on)); err != nil {
return PauseResult{}, err
}
out, err := dunstctl("is-paused")
if err != nil {
return PauseResult{}, err
}
paused := strings.TrimSpace(out) == "true"
note := "notifications are shown"
if paused {
note = "notifications are held back until dunst_resume; the next login starts unpaused"
}
return PauseResult{Paused: paused, Note: note}, nil
}
// CloseAll closes what is on screen.
func CloseAll() (CountResult, error) {
if _, err := dunstctl("close-all"); err != nil {
return CountResult{}, err
}
return Count()
}
// CountResult is what dunst_count answers.
type CountResult struct {
Displayed int `json:"displayed"`
Waiting int `json:"waiting"`
History int `json:"history"`
Paused bool `json:"paused"`
}
// Count is how many notifications are where.
func Count() (CountResult, error) {
var c CountResult
for _, part := range []struct {
which string
into *int
}{{"displayed", &c.Displayed}, {"waiting", &c.Waiting}, {"history", &c.History}} {
out, err := dunstctl("count", part.which)
if err != nil {
return c, err
}
n, err := strconv.Atoi(strings.TrimSpace(out))
if err != nil {
return c, fmt.Errorf("dunstctl count %s answered %q", part.which, out)
}
*part.into = n
}
out, err := dunstctl("is-paused")
if err != nil {
return c, err
}
c.Paused = strings.TrimSpace(out) == "true"
return c, nil
}
// Rule is one notifier rule in force.
type Rule struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Sets map[string]any `json:"sets"`
}
// RulesResult is what dunst_rules answers.
type RulesResult struct {
Files []string `json:"files"`
Rules []Rule `json:"rules"`
}
// Rules are the rules the running notifier holds, and the files it reads them from.
func Rules() (RulesResult, error) {
raw, err := dunstctl("rules", "--json")
if err != nil {
return RulesResult{}, err
}
return parseRules(raw, configFiles(filepath.Join(operatorHome(), ".config", "dunst")))
}
func parseRules(raw string, files []string) (RulesResult, error) {
entries, err := variantMaps(raw)
if err != nil {
return RulesResult{}, err
}
out := RulesResult{Files: files, Rules: []Rule{}}
for _, e := range entries {
r := Rule{Name: str(e["name"]), Enabled: e["enabled"] == true, Sets: map[string]any{}}
for k, v := range e {
if k != "name" && k != "enabled" {
r.Sets[k] = v
}
}
out.Rules = append(out.Rules, r)
}
sort.SliceStable(out.Rules, func(i, j int) bool { return out.Rules[i].Name < out.Rules[j].Name })
return out, nil
}
// configFiles are dunstrc and its drop-ins in the order dunst reads them.
func configFiles(dir string) []string {
files := []string{}
if _, err := os.Stat(filepath.Join(dir, "dunstrc")); err == nil {
files = append(files, filepath.Join(dir, "dunstrc"))
}
dropins, _ := filepath.Glob(filepath.Join(dir, "dunstrc.d", "*.conf"))
sort.Strings(dropins)
return append(files, dropins...)
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"errors"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
// withBus gives the fake machine the account's runtime directory and bus socket.
func withBus(t *testing.T) string {
t.Helper()
root := fakeMachine(t)
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
if err := os.MkdirAll(runtime, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
t.Fatal(err)
}
return root
}
// A history as dunstctl answers it (busctl's JSON), two entries out of order.
const history = `{"type":"aa{sv}","data":[[
{"body":{"type":"s","data":"the build is green"},"summary":{"type":"s","data":"CI"},"appname":{"type":"s","data":"mesh"},
"category":{"type":"s","data":""},"id":{"type":"i","data":7},"timestamp":{"type":"x","data":100000000},"urgency":{"type":"s","data":"NORMAL"}},
{"body":{"type":"s","data":""},"summary":{"type":"s","data":"Battery low"},"appname":{"type":"s","data":"upower"},
"category":{"type":"s","data":"device"},"id":{"type":"i","data":9},"timestamp":{"type":"x","data":160000000},"urgency":{"type":"s","data":"CRITICAL"}}
]]}`
func TestTheHistoryIsNewestFirstWithAgesFromDunstsOwnClock(t *testing.T) {
got, err := parseHistory(history, 200_000_000, 20)
if err != nil {
t.Fatal(err)
}
want := []Shown{
{ID: 9, AppName: "upower", Summary: "Battery low", Urgency: "critical", Category: "device", AgeSeconds: 40},
{ID: 7, AppName: "mesh", Summary: "CI", Body: "the build is green", Urgency: "normal", AgeSeconds: 100},
}
if got.Total != 2 || !reflect.DeepEqual(got.Notifications, want) {
t.Fatalf("%+v", got)
}
if got, _ := parseHistory(history, 200_000_000, 1); len(got.Notifications) != 1 || got.Total != 2 {
t.Fatalf("limited: %+v", got)
}
if _, err := parseHistory(`{"type":"as","data":[]}`, 0, 1); err == nil {
t.Fatal("an answer of another type was accepted")
}
if monotonicMicros() <= 0 {
t.Fatal("the monotonic clock")
}
}
func TestSendAsksNotifySendOverTheAccountsBusAndAnswersTheId(t *testing.T) {
withBus(t)
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo >> "$LOG"; echo "bus=$DBUS_SESSION_BUS_ADDRESS" >> "$LOG"; echo 42`})
t.Setenv("LOG", filepath.Join(bin, "log"))
n, err := notificationOf(map[string]any{"summary": "-dash title", "body": "hello", "urgency": "critical", "expire_ms": float64(0)})
if err != nil {
t.Fatal(err)
}
got, err := Send(n)
if err != nil || got.ID != 42 {
t.Fatalf("%+v, %v", got, err)
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
want := "[--print-id][--urgency=critical][--app-name=mesh][--expire-time=0][--][-dash title][hello]\nbus=unix:path=" +
filepath.Join(runUserDir, strconv.Itoa(os.Getuid()), "bus") + "\n"
if string(asked) != want {
t.Fatalf("notify-send was asked:\n%s\nwant:\n%s", asked, want)
}
}
func TestANotificationIsRefusedForWhatItCannotBe(t *testing.T) {
for _, bad := range []map[string]any{{}, {"summary": " "}, {"summary": "x", "urgency": "urgent"}, {"summary": "x", "expire_ms": float64(-5)}} {
if _, err := notificationOf(bad); err == nil {
t.Errorf("accepted %v", bad)
}
}
n, _ := notificationOf(map[string]any{"summary": "x"})
if n.Urgency != "normal" || n.AppName != "mesh" || n.ExpireMS != -1 {
t.Fatalf("defaults: %+v", n)
}
}
func TestWithoutABusTheToolsSaySo(t *testing.T) {
fakeMachine(t)
if _, err := Send(Notification{Summary: "x"}); !errors.Is(err, ErrNoBus) {
t.Fatal(err)
}
if _, err := Count(); !errors.Is(err, ErrNoBus) {
t.Fatal(err)
}
}
func TestCountPauseAndCloseAllAreDunstctlsAnswers(t *testing.T) {
withBus(t)
bin := fakeBinaries(t, map[string]string{"dunstctl": `echo "$*" >> "$LOG"
case "$*" in
"count displayed") echo 1 ;;
"count waiting") echo 0 ;;
"count history") echo 12 ;;
is-paused) cat "$STATE" 2>/dev/null || echo false ;;
"set-paused true") echo true > "$STATE" ;;
"set-paused false") echo false > "$STATE" ;;
esac`})
t.Setenv("LOG", filepath.Join(bin, "log"))
t.Setenv("STATE", filepath.Join(bin, "paused"))
c, err := Count()
if err != nil || c != (CountResult{Displayed: 1, History: 12}) {
t.Fatalf("%+v, %v", c, err)
}
p, err := SetPaused(true)
if err != nil || !p.Paused || !strings.Contains(p.Note, "held back") {
t.Fatalf("%+v, %v", p, err)
}
if c, _ := CloseAll(); !c.Paused {
t.Fatalf("close-all answers the counts: %+v", c)
}
if p, _ := SetPaused(false); p.Paused {
t.Fatalf("resumed: %+v", p)
}
log, _ := os.ReadFile(filepath.Join(bin, "log"))
if !strings.Contains(string(log), "close-all\n") {
t.Fatalf("dunstctl was asked:\n%s", log)
}
}
func TestRulesAreTheRunningNotifiersWithTheFilesTheyComeFrom(t *testing.T) {
root := t.TempDir()
for _, f := range []string{"dunstrc", "dunstrc.d/50-slack.conf", "dunstrc.d/10-mail.conf", "dunstrc.d/notes.txt"} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
t.Fatal(err)
}
}
raw := `{"type":"aa{sv}","data":[[{"enabled":{"type":"b","data":true},"appname":{"type":"s","data":"Slack"},
"fc":{"type":"s","data":"#6715ebff"},"name":{"type":"s","data":"slack"},"timeout":{"type":"x","data":10000000}}]]}`
got, err := parseRules(raw, configFiles(root))
if err != nil {
t.Fatal(err)
}
if len(got.Rules) != 1 || got.Rules[0].Name != "slack" || !got.Rules[0].Enabled || got.Rules[0].Sets["appname"] != "Slack" {
t.Fatalf("%+v", got)
}
var names []string
for _, f := range got.Files {
rel, _ := filepath.Rel(root, f)
names = append(names, rel)
}
if !reflect.DeepEqual(names, []string{"dunstrc", "dunstrc.d/10-mail.conf", "dunstrc.d/50-slack.conf"}) {
t.Fatalf("files: %v", names)
}
}
+91
View File
@@ -0,0 +1,91 @@
// dunst's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of
// node-notifier's verbs `send` and `history`, and its own tools, served by the node's runtime as the
// operator account. Everything here goes over the account's session bus; none of it needs the screen.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "node-notifier.send",
Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " +
"normal, critical), and optionally the sending application's name, an icon and how long it stays. " +
"Answers the notification's id.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"summary": map[string]any{"type": "string", "description": "the title"},
"body": map[string]any{"type": "string", "description": "the text; simple markup (<b>, <i>, <u>, <a href>) is shown"},
"urgency": map[string]any{"type": "string", "enum": urgencies, "description": "default normal"},
"app_name": map[string]any{"type": "string", "description": "who it is from (default: mesh); a notifier rule can match it"},
"icon": map[string]any{"type": "string", "description": "an icon name from the icon theme, or a file"},
"expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"},
"category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"},
"replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"},
},
"required": []string{"summary"},
},
Run: func(args map[string]any) (any, error) {
n, err := notificationOf(args)
if err != nil {
return nil, err
}
return Send(n)
},
},
{
Name: "node-notifier.history",
Description: "The notifications the operator was shown, newest first: id, application, title, " +
"body, urgency and how long ago.",
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "at most this many (default 20, at most 200)"},
},
Run: func(args map[string]any) (any, error) {
limit, err := whole(args, "limit", 20, 1, 200)
if err != nil {
return nil, err
}
return History(limit)
},
},
{
Name: "dunst_pause",
Description: "Do not disturb: hold every new notification back until resumed. They are shown then, not lost.",
Run: func(map[string]any) (any, error) { return SetPaused(true) },
},
{
Name: "dunst_resume",
Description: "End do-not-disturb: notifications held back are shown.",
Run: func(map[string]any) (any, error) { return SetPaused(false) },
},
{
Name: "dunst_close_all",
Description: "Close every notification on screen. They stay in the history.",
Run: func(map[string]any) (any, error) { return CloseAll() },
},
{
Name: "dunst_rules",
Description: "The notifier's rules in force — each rule's name, whether it is enabled, what it " +
"matches and what it sets — and the files they come from (the mesh's dunstrc, then dunstrc.d).",
Run: func(map[string]any) (any, error) { return Rules() },
},
{
Name: "dunst_count",
Description: "How many notifications are shown, waiting and in the history, and whether do-not-disturb is on.",
Run: func(map[string]any) (any, error) { return Count() },
},
}
}
@@ -0,0 +1,175 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type environment struct {
Variables map[string]string `json:"variables"`
Path []map[string]any `json:"path"`
}
type shellCode struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(t *testing.T, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
func (m manifest) packages() (present, absent []string) {
for _, r := range m.Resources {
if r["type"] == "package" {
if r["absent"] == true {
absent = append(absent, r["package"].(string))
} else {
present = append(present, r["package"].(string))
}
}
}
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
t.Helper()
own, seat := map[string]bool{}, map[string]bool{}
for _, tool := range tools() {
if strings.Contains(tool.Name, ".") {
seat[tool.Name] = true
} else {
own[tool.Name] = true
}
if strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s has no description", tool.Name)
}
}
listed := map[string]bool{}
for _, name := range m.Tools {
listed[name] = true
if !own[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
}
for name := range own {
if !listed[name] {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
t.Errorf("%s is not prefixed with the module's name", name)
}
}
promised := map[string]bool{}
for _, c := range m.Claims {
for _, verb := range c.Serves {
promised[c.Name+"."+verb] = true
if !seat[c.Name+"."+verb] {
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
}
}
}
for name := range seat {
if !promised[name] {
t.Errorf("the bundle serves %s, which no claim promises", name)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
}
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -0,0 +1,77 @@
package main
import (
"reflect"
"strings"
"testing"
)
// dunst's shape (novox/hq ADR 0208): it claims node-notifier serving send and history, owns its
// dunstrc and the drop-in directory other modules' rules go in, and starts nothing — D-Bus starts it
// on the first notification. It draws only once a notification arrives, through the bus's
// activation, so it requires no display of its own.
func TestItClaimsTheNotifierSeatServingSendAndHistory(t *testing.T) {
m := readManifest(t)
if m.Module != "dunst" || m.Seats != nil {
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
}
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-notifier", Scope: "node", Serves: []string{"send", "history"}}}) {
t.Fatalf("claims: %+v", m.Claims)
}
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"dunst", "libnotify"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
func TestItOwnsItsFileAndTheDropInDirectory(t *testing.T) {
m := readManifest(t)
m.sameAsSource(t, "configuration", "files/dunstrc")
if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/dunst/dunstrc" {
t.Fatalf("path: %v", p)
}
if d := m.resource(t, "dropins"); d["type"] != "directory" || d["path"] != "${machine:account-home}/.config/dunst/dunstrc.d" {
t.Fatalf("drop-ins: %v", d)
}
for _, r := range m.Resources {
if p, _ := r["path"].(string); strings.Contains(p, "dunstrc.d/") {
t.Fatalf("a rule of another module's: %v", r)
}
}
}
func TestTheFileIsTheDecidedOneAndCallsTheSeatsMenu(t *testing.T) {
m := readManifest(t)
c := m.resource(t, "configuration")["content"].(string)
for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter 10", "dmenu = dmenu -p dunst"} {
if !strings.Contains(c, " "+want+"\n") {
t.Errorf("lacks %q", want)
}
}
for _, never := range []string{"/usr/bin/dmenu", "Hack", "icon_path", "/home/"} {
if strings.Contains(c, never) {
t.Errorf("names %q", never)
}
}
}
func TestNothingStartsItButTheBus(t *testing.T) {
m := readManifest(t)
if m.Shell != nil {
t.Fatalf("a session start: %+v", m.Shell)
}
for _, r := range m.Resources {
if r["type"] == "service" || r["type"] == "process" {
t.Fatalf("a unit: %v", r)
}
if p, _ := r["path"].(string); strings.Contains(p, "autostart") || strings.Contains(p, "i3/config.d") {
t.Fatalf("a start: %v", r)
}
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m := readManifest(t)
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
+423
View File
@@ -0,0 +1,423 @@
// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
// the account that is part of the session (the window manager first), the same thing `loginctl` and
// a person's own shell would point at, and says where it found them.
//
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
// die with it.
//
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
// second consumer outside the desktop wants it.
package main
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Where the session is looked for. Variables so a test can point them at a fake tree.
var (
procRoot = "/proc"
runUserDir = "/run/user"
x11Sockets = "/tmp/.X11-unix"
)
// sessionHolders are the processes whose environment is the session's, best first: the window
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
// Session is what a tool needs to reach the operator's desktop.
type Session struct {
UID int `json:"uid"`
Display string `json:"display,omitempty"`
XAuthority string `json:"xauthority,omitempty"`
Wayland string `json:"wayland_display,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
SessionID string `json:"session_id,omitempty"`
I3Sock string `json:"i3sock,omitempty"`
// From says where the values were found: the tool's own environment, a process, or the socket.
From string `json:"from"`
}
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
var ErrNoSession = errors.New("no graphical session")
// ErrTimedOut is what run answers for a command ended because it ran past its time.
var ErrTimedOut = errors.New("timed out")
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
var ErrNoBus = errors.New("no session bus")
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
// findSession finds the graphical session of the account this tool runs as, or answers
// ErrNoSession with what it looked at.
func findSession() (Session, error) {
s := findEnvironment()
if s.Display == "" && s.Wayland == "" {
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
}
return s, nil
}
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
// is running.
func findBus() (Session, error) {
s := findEnvironment()
if s.Bus == "" {
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
}
return s, nil
}
func findEnvironment() Session {
uid := os.Getuid()
s := Session{UID: uid}
own := map[string]string{}
for _, k := range sessionKeys {
own[k] = os.Getenv(k)
}
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
s.fill(own)
s.From = "the tool's own environment"
} else if pid, comm, env, ok := sessionProcess(uid); ok {
s.fill(env)
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
} else if display, ok := lonelyX11Socket(); ok {
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
s.Display, s.XAuthority = display, a
s.From = "the X server socket and the account's ~/.Xauthority"
}
s.fill(own)
} else {
s.fill(own)
s.From = "nothing: no session found"
}
// The bus and the runtime directory are the account's, whether or not the process named them.
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
if s.RuntimeDir == "" && exists(runtime) {
s.RuntimeDir = runtime
}
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s
}
func (s *Session) fill(env map[string]string) {
set := func(dst *string, key string) {
if *dst == "" {
*dst = env[key]
}
}
set(&s.Display, "DISPLAY")
set(&s.XAuthority, "XAUTHORITY")
set(&s.Wayland, "WAYLAND_DISPLAY")
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
set(&s.SessionID, "XDG_SESSION_ID")
set(&s.I3Sock, "I3SOCK")
}
// sessionProcess is the best process of this uid whose environment names a display.
func sessionProcess(uid int) (int, string, map[string]string, bool) {
entries, err := os.ReadDir(procRoot)
if err != nil {
return 0, "", nil, false
}
type candidate struct {
pid int
comm string
env map[string]string
rank int
}
var found []candidate
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(procRoot, e.Name())
if owner, ok := ownerOf(dir); !ok || owner != uid {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
env := parseEnviron(raw)
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
break
}
}
found = append(found, candidate{pid, comm, env, rank})
}
if len(found) == 0 {
return 0, "", nil, false
}
sort.Slice(found, func(i, j int) bool {
if found[i].rank != found[j].rank {
return found[i].rank < found[j].rank
}
return found[i].pid > found[j].pid // the newer of two equals
})
best := found[0]
return best.pid, best.comm, best.env, true
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
func ownerOf(path string) (int, bool) {
info, err := os.Stat(path)
if err != nil {
return 0, false
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return int(st.Uid), true
}
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
func lonelyX11Socket() (string, bool) {
entries, err := os.ReadDir(x11Sockets)
if err != nil {
return "", false
}
var displays []string
for _, e := range entries {
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
if _, err := strconv.Atoi(n); err == nil {
displays = append(displays, ":"+n)
}
}
}
if len(displays) != 1 {
return "", false
}
return displays[0], true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Env is this process's environment with the session's variables in place of its own.
func (s Session) Env() []string {
drop := map[string]bool{}
for _, k := range sessionKeys {
drop[k] = true
}
var env []string
for _, kv := range os.Environ() {
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
continue
}
env = append(env, kv)
}
add := func(k, v string) {
if v != "" {
env = append(env, k+"="+v)
}
}
add("DISPLAY", s.Display)
add("XAUTHORITY", s.XAuthority)
add("WAYLAND_DISPLAY", s.Wayland)
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
add("XDG_RUNTIME_DIR", s.RuntimeDir)
add("XDG_SESSION_ID", s.SessionID)
add("I3SOCK", s.I3Sock)
return env
}
// mostOutput bounds what a command may answer with, per stream.
const mostOutput = 256 << 10
// Result is what a command did.
type Result struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr,omitempty"`
Code int `json:"code"`
Truncated bool `json:"truncated,omitempty"`
}
// run runs a command in the session's environment, its input given, ended with everything it
// started after timeout. A command that is not installed is an error naming it; one that exits
// non-zero is a Result with its code, for the caller to judge.
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
path, err := exec.LookPath(name)
if err != nil {
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
}
cmd := exec.Command(path, args...)
cmd.Env = s.Env()
if home := operatorHome(); exists(home) {
cmd.Dir = home
}
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var out, errOut capped
cmd.Stdout, cmd.Stderr = &out, &errOut
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return Result{}, fmt.Errorf("%s: %w", name, err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err = <-done:
case <-time.After(timeout):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-done
return Result{Stdout: out.String(), Stderr: errOut.String()},
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
}
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
var exit *exec.ExitError
if errors.As(err, &exit) {
r.Code = exit.ExitCode()
} else if err != nil {
return r, fmt.Errorf("%s: %w", name, err)
}
return r, nil
}
// detach starts a long-lived program under the account's own service manager, as a transient unit
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
// running under the same name is stopped first, so a fixed name means "at most one".
func (s Session) detach(unit string, args ...string) error {
if s.RuntimeDir == "" {
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
"cannot be reached", ErrNoBus)
}
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--")
call = append(call, args...)
r, err := s.run(10*time.Second, "", "systemd-run", call...)
if err != nil {
return err
}
if r.Code != 0 {
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
}
return nil
}
// uniqueUnit is a transient unit name that will not collide with an earlier one.
func uniqueUnit(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
type capped struct {
bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := mostOutput - c.Len(); room < len(p) {
if room > 0 {
c.Buffer.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.Buffer.Write(p)
}
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
func processesOf(comm string) []int {
entries, err := os.ReadDir(procRoot)
if err != nil {
return nil
}
uid := os.Getuid()
var pids []int
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(procRoot, e.Name())
if owner, ok := ownerOf(dir); !ok || owner != uid {
continue
}
if readTrimmed(filepath.Join(dir, "comm")) == comm {
pids = append(pids, pid)
}
}
sort.Ints(pids)
return pids
}
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
func signalAll(comm string, sig syscall.Signal) []int {
var reached []int
for _, pid := range processesOf(comm) {
if syscall.Kill(pid, sig) == nil {
reached = append(reached, pid)
}
}
return reached
}
@@ -0,0 +1,174 @@
package main
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
// none of the test process's own session variables, and gives back the root.
func fakeMachine(t *testing.T) string {
t.Helper()
root := t.TempDir()
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
for _, k := range sessionKeys {
t.Setenv(k, "")
}
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
return root
}
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
t.Helper()
dir := filepath.Join(procRoot, strconv.Itoa(pid))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
t.Fatal(err)
}
}
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
s, err := findSession()
if err != nil {
t.Fatal(err)
}
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
t.Fatalf("the window manager's environment: %+v", s)
}
for _, kv := range s.Env() {
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
t.Fatal("a variable of the session process that is not a session variable was handed on")
}
}
}
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
s, err := findSession()
if err != nil || s.Display != ":2" {
t.Fatalf("the newest of two equals: %+v, %v", s, err)
}
}
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
_, err := findSession()
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
t.Fatalf("no session: %v", err)
}
}
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
root := fakeMachine(t)
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
t.Fatal(err)
}
s, err := findSession()
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
t.Fatalf("socket and authority: %+v, %v", s, err)
}
}
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
fakeMachine(t)
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
t.Fatalf("no runtime directory is no bus: %v", err)
}
if err := os.MkdirAll(runtime, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
t.Fatal(err)
}
s, err := findBus()
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
t.Fatalf("bus: %+v, %v", s, err)
}
env := strings.Join(s.Env(), "\n")
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
t.Fatalf("the bus is handed on: %s", env)
}
}
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
fakeMachine(t)
s := Session{}
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
t.Fatalf("result: %+v, %v", r, err)
}
start := time.Now()
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
}
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
t.Fatalf("a missing program: %v", err)
}
}
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
fakeMachine(t)
bin := fakeBinaries(t, map[string]string{
"systemctl": `echo "systemctl $*" >> "$LOG"`,
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
})
log := filepath.Join(bin, "log")
t.Setenv("LOG", log)
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(log)
want := "systemctl --user stop picom-session.service\n" +
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
if string(got) != want {
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
}
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
t.Fatalf("no runtime directory: %v", err)
}
}
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
func fakeBinaries(t *testing.T, scripts map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, body := range scripts {
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return dir
}
+92
View File
@@ -0,0 +1,92 @@
# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced
# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in
# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the
# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.
#
# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,
# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.
[global]
monitor = 0
follow = none
# Geometry
width = 250
height = (0, 300)
origin = top-right
offset = (10, 50)
notification_limit = 20
progress_bar = true
progress_bar_height = 10
progress_bar_frame_width = 1
progress_bar_min_width = 150
progress_bar_max_width = 300
indicate_hidden = yes
transparency = 0
separator_height = 2
padding = 8
horizontal_padding = 8
text_icon_padding = 0
frame_width = 3
frame_color = "#de5200"
gap_size = 0
separator_color = frame
sort = yes
corner_radius = 0
# Text: the interface face (research 026/04)
font = Inter 10
line_height = 0
markup = full
format = "<b>%s</b>\n%b"
alignment = left
vertical_alignment = center
show_age_threshold = 60
ellipsize = middle
ignore_newline = no
stack_duplicates = true
hide_duplicate_count = false
show_indicators = yes
# Icons, from the desktop's icon theme
enable_recursive_icon_lookup = true
icon_theme = Adwaita
icon_position = left
min_icon_size = 32
max_icon_size = 128
# History
sticky_history = yes
history_length = 20
# The context menu is the node's dmenu-compatible command, which the holder of node-launcher
# answers (rofi on the workstations). Links open in the desktop's default browser.
dmenu = dmenu -p dunst
browser = /usr/bin/xdg-open
always_run_script = true
title = Dunst
class = Dunst
ignore_dbusclose = false
mouse_left_click = close_current
mouse_middle_click = do_action, close_current
mouse_right_click = close_all
[urgency_low]
background = "#000000"
foreground = "#ffffff"
timeout = 10
[urgency_normal]
background = "#000000"
foreground = "#ffffff"
timeout = 10
[urgency_critical]
background = "#000000"
foreground = "#ffffff"
frame_color = "#ff0000"
timeout = 0
+5
View File
@@ -0,0 +1,5 @@
module dunst
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": "dunst",
"version": "1",
"capabilities": [
"package-manager"
],
"claims": [
{
"name": "node-notifier",
"scope": "node",
"serves": [
"send",
"history"
]
}
],
"tools": [
"dunst_pause",
"dunst_resume",
"dunst_close_all",
"dunst_rules",
"dunst_count"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "dunst"
},
{
"id": "client",
"type": "package",
"package": "libnotify"
},
{
"id": "configuration-dir",
"type": "directory",
"path": "${machine:account-home}/.config/dunst",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "dropins",
"type": "directory",
"path": "${machine:account-home}/.config/dunst/dunstrc.d",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "configuration",
"type": "file",
"path": "${machine:account-home}/.config/dunst/dunstrc",
"owner": "${machine:account}",
"mode": "0644",
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = 250\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter 10\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/dunst-tools",
"binary": "dunst-tools",
"loads": [
"dunst-tools"
]
}
]
}
}