Let the operator edit the station config in the window instead of hand-editing the file and relaunching.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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 reconnect || !wasLoaded {
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
if cfg.certFatal != "" {
|
||||
return loaded{}, form, errors.New(cfg.certFatal)
|
||||
}
|
||||
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 {
|
||||
return werr
|
||||
}
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
Reference in New Issue
Block a user