diff --git a/modules/dunst/README.md b/modules/dunst/README.md new file mode 100644 index 0000000..59c90c8 --- /dev/null +++ b/modules/dunst/README.md @@ -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. diff --git a/modules/dunst/cmd/dunst-tools/args.go b/modules/dunst/cmd/dunst-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/args.go @@ -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 +} diff --git a/modules/dunst/cmd/dunst-tools/dunst.go b/modules/dunst/cmd/dunst-tools/dunst.go new file mode 100644 index 0000000..a05c3e9 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/dunst.go @@ -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...) +} diff --git a/modules/dunst/cmd/dunst-tools/dunst_test.go b/modules/dunst/cmd/dunst-tools/dunst_test.go new file mode 100644 index 0000000..7faae0f --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/dunst_test.go @@ -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) + } +} diff --git a/modules/dunst/cmd/dunst-tools/main.go b/modules/dunst/cmd/dunst-tools/main.go new file mode 100644 index 0000000..bd61453 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/main.go @@ -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 (, , , ) 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() }, + }, + } +} diff --git a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go @@ -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 ., 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) + } + } +} diff --git a/modules/dunst/cmd/dunst-tools/manifest_test.go b/modules/dunst/cmd/dunst-tools/manifest_test.go new file mode 100644 index 0000000..6c7fb3e --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/manifest_test.go @@ -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) +} diff --git a/modules/dunst/cmd/dunst-tools/session.go b/modules/dunst/cmd/dunst-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/session.go @@ -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 +} diff --git a/modules/dunst/cmd/dunst-tools/session_test.go b/modules/dunst/cmd/dunst-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/session_test.go @@ -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 +} diff --git a/modules/dunst/files/dunstrc b/modules/dunst/files/dunstrc new file mode 100644 index 0000000..f504485 --- /dev/null +++ b/modules/dunst/files/dunstrc @@ -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 = "%s\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 diff --git a/modules/dunst/go.mod b/modules/dunst/go.mod new file mode 100644 index 0000000..d26e306 --- /dev/null +++ b/modules/dunst/go.mod @@ -0,0 +1,5 @@ +module dunst + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/dunst/go.sum b/modules/dunst/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/dunst/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/dunst/module.json b/modules/dunst/module.json new file mode 100644 index 0000000..6718098 --- /dev/null +++ b/modules/dunst/module.json @@ -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 = \"%s\\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" + ] + } + ] + } +}