319 lines
8.2 KiB
Go
319 lines
8.2 KiB
Go
package config
|
|
|
|
import "os"
|
|
import "log"
|
|
import "sync"
|
|
import "slices"
|
|
import "path/filepath"
|
|
import "github.com/fsnotify/fsnotify"
|
|
import "git.tebibyte.media/tomo/tomo"
|
|
import "git.tebibyte.media/tomo/tomo/event"
|
|
|
|
// Goroutine model:
|
|
// All private methods (except for lockAndProcessEvent) do not lock the config,
|
|
// but all public methods do. Private methods may not call public methods.
|
|
// Locking must always be method-level, with a call to Lock at the start,
|
|
// directly followed by a deferred call to Unlock.
|
|
|
|
type config struct {
|
|
open bool
|
|
watcher *fsnotify.Watcher
|
|
lock sync.RWMutex
|
|
ignoreNextUserUpdate bool
|
|
|
|
paths struct {
|
|
user string
|
|
system []string
|
|
watching map[string] struct { }
|
|
}
|
|
|
|
data struct {
|
|
user *File
|
|
system []map[string] Value
|
|
}
|
|
|
|
on struct {
|
|
change event.Broadcaster[func (string)]
|
|
}
|
|
}
|
|
|
|
// NewConfig creates a new Config using paths to the user-level config file, and
|
|
// a set of system config files. These files need not exist: the user-level file
|
|
// will be created when Set is called for the first time if it does not exist
|
|
// already, and nonexistent system files are simply ignored (unless the Config
|
|
// finds them at any point to have been spontaneously created).
|
|
//
|
|
// The user file is written to when Set is called, and the system files are only
|
|
// read from. Values in the user file override those in the system files, and
|
|
// system files specified nearer to the start of the vararg list will override
|
|
// those farther down.
|
|
func NewConfig (user string, system ...string) (ConfigCloser, error) {
|
|
conf := new(config)
|
|
conf.paths.user = user
|
|
conf.paths.system = system
|
|
err := conf.init()
|
|
if err != nil { return nil, err }
|
|
go func () {
|
|
for event := range conf.watcher.Events {
|
|
conf.lockAndProcessEvent(event)
|
|
}
|
|
} ()
|
|
return conf, nil
|
|
}
|
|
|
|
// this method may only be run in the goroutine spawned by NewConfig.
|
|
func (this *config) lockAndProcessEvent (event fsnotify.Event) {
|
|
this.lock.Lock()
|
|
defer this.lock.Unlock()
|
|
if !(event.Has(fsnotify.Write)) { return }
|
|
if _, ok := this.paths.watching[event.Name]; !ok { return }
|
|
|
|
if event.Name == this.paths.user {
|
|
if !this.ignoreNextUserUpdate {
|
|
previousUser := this.data.user
|
|
this.reloadUser()
|
|
newUser := this.data.user
|
|
this.processUserDiff(newUser.Diff(previousUser))
|
|
}
|
|
this.ignoreNextUserUpdate = false
|
|
} else {
|
|
index := slices.Index(this.paths.system, event.Name)
|
|
if index > 0 {
|
|
previousSystem := this.data.system[index]
|
|
this.reloadSystem(index)
|
|
newSystem := this.data.system[index]
|
|
this.processSystemDiff(index, diffValueMaps(newSystem, previousSystem))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (this *config) init () error {
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil { return err }
|
|
this.watcher = watcher
|
|
|
|
this.paths.watching = make(map[string] struct { })
|
|
this.watcher.Add(filepath.Dir(this.paths.user))
|
|
this.paths.watching[this.paths.user] = struct { } { }
|
|
this.reloadUser()
|
|
for index, path := range this.paths.system {
|
|
this.watcher.Add(filepath.Dir(path))
|
|
this.paths.watching[path] = struct { } { }
|
|
this.reloadSystem(index)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (this *config) errIfClosed () error {
|
|
if this.open {
|
|
return nil
|
|
} else {
|
|
return ErrClosed
|
|
}
|
|
}
|
|
|
|
func (this *config) reloadUser () {
|
|
file, err := os.Open(this.paths.user)
|
|
if err != nil { return }
|
|
defer file.Close()
|
|
|
|
userFile, err := Parse(file)
|
|
if err != nil {
|
|
log.Printf("nasin: problems loading user config file %s: %v", this.paths.user, err)
|
|
return
|
|
}
|
|
this.data.user = userFile
|
|
}
|
|
|
|
func (this *config) reloadSystem (index int) {
|
|
path := this.paths.system[index]
|
|
file, err := os.Open(path)
|
|
if err != nil { return }
|
|
defer file.Close()
|
|
|
|
systemFile, err := Parse(file)
|
|
if err != nil {
|
|
log.Printf("nasin: problems loading system config file %s: %v", path, err)
|
|
return
|
|
}
|
|
this.data.system[index] = systemFile.Map()
|
|
}
|
|
|
|
func (this *config) saveUser () error {
|
|
// TODO set some sort of flag to ignore the next inotify event for the
|
|
// user file so we dont reload it immediately after. also need to
|
|
// broadacast Changed event.
|
|
enclosingDir := filepath.Dir(this.paths.user)
|
|
err := os.MkdirAll(enclosingDir, 755)
|
|
if err != nil { return err }
|
|
file, err := os.Create(this.paths.user)
|
|
if err != nil { return err }
|
|
defer file.Close()
|
|
_, err = this.data.user.WriteTo(file)
|
|
if err != nil { return err }
|
|
this.ignoreNextUserUpdate = true
|
|
return nil
|
|
}
|
|
|
|
func (this *config) processUserDiff (changed map[string] struct { }) {
|
|
for key := range changed {
|
|
// this is the user file, and nothing has precedence over it, so
|
|
// the change always matters
|
|
this.broadcastChange(key)
|
|
}
|
|
}
|
|
|
|
func (this *config) processSystemDiff (index int, changed map[string] struct { }) {
|
|
for key := range changed {
|
|
// if specified in the user file, the change doesn't matter
|
|
if this.data.user != nil {
|
|
if has, _ := this.data.user.Has(key); has {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// if specified in any system files with precedence greater than
|
|
// this one, the change doesn't matter
|
|
for _, system := range this.data.system[:index] {
|
|
if _, has := system[key]; has {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// the change does matter
|
|
this.broadcastChange(key)
|
|
}
|
|
}
|
|
|
|
func (this *config) broadcastChange (key string) {
|
|
for _, listener := range this.on.change.Listeners() {
|
|
tomo.Do(func () { listener(key) })
|
|
}
|
|
}
|
|
|
|
func (this *config) get (key string, fallback Value) (Value, error) {
|
|
// try user config
|
|
if !KeyValid(key) { return nil, ErrMalformedKey }
|
|
|
|
if this.data.user != nil {
|
|
value, err := this.data.user.Get(key)
|
|
if err == nil && err != ErrNonexistentEntry {
|
|
return value, nil
|
|
}
|
|
}
|
|
|
|
// try system configs
|
|
for _, config := range this.data.system {
|
|
if value, ok := config[key]; ok {
|
|
return value, nil
|
|
}
|
|
}
|
|
|
|
// use fallback
|
|
return fallback, nil
|
|
|
|
}
|
|
|
|
func (this *config) Get (key string, fallback Value) (Value, error) {
|
|
this.lock.Lock()
|
|
defer this.lock.Unlock()
|
|
return this.get(key, fallback)
|
|
}
|
|
|
|
func (this *config) Close () error {
|
|
this.lock.Lock()
|
|
defer this.lock.Unlock()
|
|
this.open = false
|
|
return this.watcher.Close()
|
|
}
|
|
|
|
func (this *config) GetString (key string, fallback string) (string, error) {
|
|
this.lock.Lock()
|
|
defer this.lock.Unlock()
|
|
value, err := this.get(key, ValueString(fallback))
|
|
if err != nil { return "", err }
|
|
if value, ok := value.(ValueString); ok {
|
|
return string(value), nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func (this *config) GetNumber (key string, fallback float64) (float64, error) {
|
|
this.lock.Lock()
|
|
defer this.lock.Unlock()
|
|
value, err := this.get(key, ValueNumber(fallback))
|
|
if err != nil { return 0, err }
|
|
if value, ok := value.(ValueNumber); ok {
|
|
return float64(value), nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func (this *config) GetBool (key string, fallback bool) (bool, error) {
|
|
this.lock.Lock()
|
|
defer this.lock.Unlock()
|
|
value, err := this.get(key, ValueBool(fallback))
|
|
if err != nil { return false, err }
|
|
if value, ok := value.(ValueBool); ok {
|
|
return bool(value), nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func (this *config) Set (key string, value Value) error {
|
|
this.lock.Lock()
|
|
defer this.lock.Unlock()
|
|
if this.data.user == nil { this.data.user = NewFile() }
|
|
err := this.data.user.Set(key, value)
|
|
if err != nil { return err }
|
|
err = this.saveUser()
|
|
if err != nil { return err }
|
|
this.broadcastChange(key)
|
|
return nil
|
|
}
|
|
|
|
func (this *config) Reset (key string) error {
|
|
this.lock.Lock()
|
|
defer this.lock.Unlock()
|
|
if this.data.user == nil { this.data.user = NewFile() }
|
|
err := this.data.user.Reset(key)
|
|
if err != nil { return err }
|
|
err = this.saveUser()
|
|
if err != nil { return err }
|
|
this.broadcastChange(key)
|
|
return nil
|
|
}
|
|
|
|
func (this *config) OnChange (callback func (string)) event.Cookie {
|
|
this.lock.Lock()
|
|
defer this.lock.Unlock()
|
|
return this.on.change.Connect(callback)
|
|
}
|
|
|
|
func diffValueMaps (first, second map[string] Value) map[string] struct { } {
|
|
diff := make(map[string] struct { })
|
|
|
|
// - keys only first has
|
|
// - keys both have, but are different
|
|
for key, firstValue := range first {
|
|
secondValue, ok := second[key]
|
|
if !ok {
|
|
diff[key] = struct { } { }
|
|
continue
|
|
}
|
|
if !firstValue.Equals(secondValue) {
|
|
diff[key] = struct { } { }
|
|
}
|
|
}
|
|
|
|
// - keys only second has
|
|
for key := range second {
|
|
if _, has := first[key]; !has {
|
|
diff[key] = struct { } { }
|
|
}
|
|
}
|
|
|
|
return diff
|
|
}
|