Files
funknetz/station/config_window.go
T

297 lines
6.4 KiB
Go

package station
import (
"errors"
"fmt"
"io/fs"
"os"
"strings"
"encoding/json/jsontext"
jsonv2 "encoding/json/v2"
)
// Cancel discards the Config window. It reports whether the app should quit
// because no config has loaded successfully.
func (s *Station) Cancel() bool {
s.mu.Lock()
defer s.mu.Unlock()
if !s.loaded {
return true
}
s.configOpen = false
s.configErr = ""
s.publish()
return false
}
// OpenConfig re-reads the config file into the form. The live session is kept
// when the re-read fails.
func (s *Station) OpenConfig() {
s.mu.Lock()
defer s.mu.Unlock()
if s.configOpen {
return
}
if s.buttonHeld || s.spaceHeld {
s.openBlocked = "Release push-to-talk"
s.publish()
return
}
s.openBlocked = ""
form, detail, ok := readForm(s.path)
s.configOpen = true
s.form = form
if ok {
s.configErr = ""
} else {
s.configErr = detail
}
s.publish()
}
// Save validates form, writes the config file, and closes the Config window.
// An invalid form or a write error leaves the window open and the session unchanged.
func (s *Station) Save(form ConfigForm) error {
s.mu.Lock()
defer s.mu.Unlock()
next, stored, err := validateForm(s.path, form)
if err != nil {
s.keepForm(form, err.Error())
return err
}
if err := writeForm(s.path, stored); err != nil {
msg := fmt.Errorf("the file could not be written: %w", err).Error()
s.keepForm(form, msg)
return errors.New(msg)
}
prev := s.cfg
wasLoaded := s.loaded
s.cfg = next
s.form = stored
s.loaded = true
s.configErr = ""
s.configOpen = false
s.logPath = next.logPath
s.logNotice = next.logNotice
reconnect := wasLoaded && connectionChanged(prev, next)
if next.certFatal != "" {
s.fatal = next.certFatal
s.closeLogLocked()
if next.enableLogging {
s.openLog()
}
if wasLoaded {
s.kickReconnect()
}
} else if reconnect || !wasLoaded {
if !wasLoaded || s.fatal == prev.certFatal {
s.fatal = ""
}
s.closeLogLocked()
if next.enableLogging {
s.openLog()
}
if reconnect {
s.kickReconnect()
}
} else {
s.applyLogging(prev, next)
}
s.publish()
return nil
}
func (s *Station) kickReconnect() {
s.epoch++
s.kick = true
if s.cancelDial != nil {
s.cancelDial()
}
if s.conn != nil {
// Close unblocks the session read so Run can dial again.
_ = s.conn.Close()
}
select {
case s.apply <- struct{}{}:
default:
}
}
func (s *Station) drainApply() {
select {
case <-s.apply:
default:
}
}
func (s *Station) applyLogging(prev, next loaded) {
if prev.enableLogging == next.enableLogging && prev.logPath == next.logPath && prev.logNotice == next.logNotice {
return
}
s.closeLogLocked()
s.logNotice = next.logNotice
s.logPath = next.logPath
if next.enableLogging && next.logNotice == "" {
s.openLog()
}
}
func connectionChanged(prev, next loaded) bool {
return prev.host != next.host || prev.port != next.port || prev.callsign != next.callsign ||
prev.authKey != next.authKey || prev.talkGroup != next.talkGroup || prev.certificateDir != next.certificateDir
}
func readForm(path string) (ConfigForm, string, bool) {
body, err := os.ReadFile(path)
if err != nil {
form, ferr := templateForm()
if ferr != nil {
form = ConfigForm{CertificateDir: "./funknetz"}
}
return form, fmt.Errorf("the file could not be read: %w", err).Error(), false
}
out, err := parseConfig(path, body, false)
if err != nil {
return out.form, err.Error(), false
}
if !out.loaded {
msg := out.detail
if msg == "" {
msg = "the file could not be read"
}
return out.form, msg, false
}
return out.form, "", true
}
func (s *Station) keepForm(form ConfigForm, msg string) {
s.form = form
s.configErr = msg
s.configOpen = true
s.publish()
}
func validateForm(configPath string, form ConfigForm) (loaded, ConfigForm, error) {
shape, err := shapeFromForm(form)
if err != nil {
return loaded{}, form, err
}
cfg, err := parseOperational(shape)
if err != nil {
return loaded{}, form, err
}
cfg = applyCertificate(cfg, shape, configPath)
cfg = applyLog(cfg, shape)
stored := ConfigForm{
Host: cfg.host,
Port: cfg.port,
Callsign: cfg.callsign,
AuthKey: cfg.authKey,
TalkGroup: cfg.talkGroup,
CertificateDir: strings.TrimSpace(form.CertificateDir),
EnableLogging: form.EnableLogging,
LogFile: form.LogFile,
}
return cfg, stored, nil
}
func shapeFromForm(form ConfigForm) (fileShape, error) {
host, err := jsonValue(form.Host)
if err != nil {
return fileShape{}, err
}
port, err := jsonValue(form.Port)
if err != nil {
return fileShape{}, err
}
call, err := jsonValue(form.Callsign)
if err != nil {
return fileShape{}, err
}
key, err := jsonValue(form.AuthKey)
if err != nil {
return fileShape{}, err
}
tg, err := jsonValue(form.TalkGroup)
if err != nil {
return fileShape{}, err
}
dir, err := jsonValue(form.CertificateDir)
if err != nil {
return fileShape{}, err
}
en, err := jsonValue(form.EnableLogging)
if err != nil {
return fileShape{}, err
}
logFile, err := jsonValue(form.LogFile)
if err != nil {
return fileShape{}, err
}
return fileShape{
Host: host,
Port: port,
Callsign: call,
AuthKey: key,
TalkGroup: tg,
CertificateDir: dir,
EnableLogging: en,
LogFile: logFile,
}, nil
}
func jsonValue(v any) (jsontext.Value, error) {
raw, err := jsonv2.Marshal(v)
if err != nil {
return nil, err
}
return jsontext.Value(raw), nil
}
func writeForm(path string, form ConfigForm) error {
body, err := jsonv2.Marshal(templateFile{
Host: form.Host,
Port: form.Port,
Callsign: form.Callsign,
AuthKey: form.AuthKey,
TalkGroup: form.TalkGroup,
CertificateDir: form.CertificateDir,
EnableLogging: form.EnableLogging,
LogFile: form.LogFile,
})
if err != nil {
return err
}
body = append(body, '\n')
info, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return createExclusive(path, body)
}
if err != nil {
return err
}
return rewriteBytes(path, body, info.Mode().Perm())
}
func createExclusive(path string, body []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
_, werr := f.Write(body)
cerr := f.Close()
if werr != nil || cerr != nil {
os.Remove(path)
if werr != nil {
return werr
}
return cerr
}
if err := os.Chmod(path, 0o600); err != nil {
os.Remove(path)
return err
}
return nil
}