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...) }