// Copyright (C) 2026 Danilo M. // // 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) }