diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-19 09:15:38 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-19 09:15:38 +0200 |
| commit | 75acc11af55b4efe0ea095a52a3916b13639c2cd (patch) | |
| tree | 9e56593a3c6ef8aa7b6d03c9408d3239a1d8a582 | |
| parent | a8003f630728b8e4b5f2b163d9be2837da146b23 (diff) | |
| download | notifyd-75acc11af55b4efe0ea095a52a3916b13639c2cd.tar.gz notifyd-75acc11af55b4efe0ea095a52a3916b13639c2cd.zip | |
feat(notify): read timeouts and caps from config.json
A missing or broken file is the defaults, so a hand typo never stops the
daemon; a key of the wrong type keeps its default while the rest applies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | internal/notify/config.go | 91 | ||||
| -rw-r--r-- | internal/notify/config_test.go | 65 |
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) + } +} |
