1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
|
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
package notify
import (
"encoding/json"
"errors"
"log"
"os"
"path/filepath"
)
// Timeouts are the balloon lifetimes in seconds per urgency, used only when a
// client leaves the choice to the server. 0 means never expires.
type Timeouts struct {
Low int `json:"low"`
Normal int `json:"normal"`
Critical int `json:"critical"`
}
// Config is the daemon's share of ~/.config/notifyd/config.json. The file
// also carries the balloon shell's keys (placement, DND, mute); they are not
// declared here and the decoder ignores them.
type Config struct {
Timeouts Timeouts `json:"timeouts"`
LiveCap int `json:"liveCap"`
HistoryCap int `json:"historyCap"`
}
// DefaultConfig is the behaviour before the file existed.
func DefaultConfig() Config {
return Config{Timeouts: Timeouts{Low: 10, Normal: 10, Critical: 0}, LiveCap: 20, HistoryCap: 20}
}
// ConfigPath is $XDG_CONFIG_HOME/notifyd/config.json, ~/.config when unset.
func ConfigPath() string {
dir, err := os.UserConfigDir()
if err != nil {
dir = filepath.Join(homeDir(), ".config")
}
return filepath.Join(dir, "notifyd", "config.json")
}
// LoadConfig never fails: a hand typo must not stop the daemon. A missing
// file or broken JSON is the defaults; a key of the wrong type keeps its
// default while the rest of the file applies, which is what encoding/json
// does on an UnmarshalTypeError. Out-of-range values clamp.
func LoadConfig(path string) Config {
def := DefaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.Printf("notifyd: config: %v", err)
}
return def
}
c := def
if err := json.Unmarshal(data, &c); err != nil {
var typeErr *json.UnmarshalTypeError
if !errors.As(err, &typeErr) {
log.Printf("notifyd: config %s: %v, using defaults", path, err)
return def
}
log.Printf("notifyd: config %s: %v", path, err)
}
c.LiveCap = clamp(c.LiveCap, 1, 100)
c.HistoryCap = clamp(c.HistoryCap, 1, 100)
c.Timeouts.Low = seconds(c.Timeouts.Low, def.Timeouts.Low)
c.Timeouts.Normal = seconds(c.Timeouts.Normal, def.Timeouts.Normal)
c.Timeouts.Critical = seconds(c.Timeouts.Critical, def.Timeouts.Critical)
return c
}
// maxTimeout is a day. Beyond it nothing is meaningfully a timeout, and a
// value near 1e10 seconds would overflow time.Duration and wrap to an
// immediate or negative expiry.
const maxTimeout = 86400
// seconds keeps a timeout in 0..maxTimeout, else the default.
func seconds(v, def int) int {
if v < 0 || v > maxTimeout {
return def
}
return v
}
func clamp(v, lo, hi int) int {
return min(max(v, lo), hi)
}
|