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.
356 lines
9.2 KiB
Go
356 lines
9.2 KiB
Go
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...)
|
|
}
|