aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--internal/notify/config.go91
-rw-r--r--internal/notify/config_test.go65
2 files changed, 156 insertions, 0 deletions
diff --git a/internal/notify/config.go b/internal/notify/config.go
new file mode 100644
index 0000000..2b6ba5e
--- /dev/null
+++ b/internal/notify/config.go
@@ -0,0 +1,91 @@
+// 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)
+ if c.Timeouts.Low < 0 {
+ c.Timeouts.Low = def.Timeouts.Low
+ }
+ if c.Timeouts.Normal < 0 {
+ c.Timeouts.Normal = def.Timeouts.Normal
+ }
+ if c.Timeouts.Critical < 0 {
+ c.Timeouts.Critical = def.Timeouts.Critical
+ }
+ return c
+}
+
+func clamp(v, lo, hi int) int {
+ return min(max(v, lo), hi)
+}
diff --git a/internal/notify/config_test.go b/internal/notify/config_test.go
new file mode 100644
index 0000000..46032ee
--- /dev/null
+++ b/internal/notify/config_test.go
@@ -0,0 +1,65 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func writeConfig(t *testing.T, body string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func TestLoadConfigMissingFileIsDefaults(t *testing.T) {
+ got := LoadConfig(filepath.Join(t.TempDir(), "absent.json"))
+ if got != DefaultConfig() {
+ t.Errorf("got %+v, want defaults", got)
+ }
+}
+
+func TestLoadConfigMalformedIsDefaults(t *testing.T) {
+ got := LoadConfig(writeConfig(t, `{"liveCap": 5,`))
+ if got != DefaultConfig() {
+ t.Errorf("got %+v, want defaults", got)
+ }
+}
+
+func TestLoadConfigPartialMergesOverDefaults(t *testing.T) {
+ got := LoadConfig(writeConfig(t, `{"timeouts": {"low": 3}, "historyCap": 50, "muted": ["x"]}`))
+ want := DefaultConfig()
+ want.Timeouts.Low = 3
+ want.HistoryCap = 50
+ if got != want {
+ t.Errorf("got %+v, want %+v", got, want)
+ }
+}
+
+func TestLoadConfigWrongTypeKeepsThatDefault(t *testing.T) {
+ got := LoadConfig(writeConfig(t, `{"liveCap": "many", "historyCap": 7}`))
+ if got.LiveCap != 20 || got.HistoryCap != 7 {
+ t.Errorf("got live %d history %d, want 20 and 7", got.LiveCap, got.HistoryCap)
+ }
+}
+
+func TestLoadConfigClamps(t *testing.T) {
+ got := LoadConfig(writeConfig(t, `{"liveCap": 0, "historyCap": 1000, "timeouts": {"normal": -4}}`))
+ if got.LiveCap != 1 || got.HistoryCap != 100 || got.Timeouts.Normal != 10 {
+ t.Errorf("got %+v", got)
+ }
+}