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:
2026-10-11 19:19:05 +02:00
co-authored by Cursor
parent e642b24be0
commit 775d1b5c82
12 changed files with 1567 additions and 179 deletions
+126 -40
View File
@@ -51,80 +51,162 @@ type templateFile struct {
var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?$`)
func loadConfig(path string) (loaded, error) {
// configLoad is the result of reading a config path. err from loadConfig is
// only a failure that still exits before the window, such as an unknown home
// directory. A missing or invalid file is reported in detail with loaded false.
type configLoad struct {
cfg loaded
form ConfigForm
loaded bool
detail string
}
func loadConfig(path string) (configLoad, error) {
body, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
if err := writeTemplate(path); err != nil {
return loaded{}, err
}
return loaded{}, fmt.Errorf("created %s", path)
form, ferr := templateForm()
if ferr != nil {
return configLoad{}, ferr
}
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
if errors.Is(err, fs.ErrNotExist) {
return configLoad{form: form}, nil
}
return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil
}
return parseConfig(path, body)
}
func writeTemplate(path string) error {
logFile, err := defaultLogPath()
if err != nil {
return err
}
body, err := jsonv2.Marshal(templateFile{CertificateDir: "./funknetz", LogFile: logFile})
if err != nil {
return err
}
body = append(body, '\n')
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)
// ConfigForm is the eight fields the Config window edits.
type ConfigForm struct {
Host string `json:"host"`
Port int `json:"port"`
Callsign string `json:"callsign"`
AuthKey string `json:"authKey"`
TalkGroup int `json:"talkGroup"`
CertificateDir string `json:"certificateDir"`
EnableLogging bool `json:"enableLogging"`
LogFile string `json:"logFile"`
}
func parseConfig(path string, body []byte) (loaded, error) {
// ConfigState is what the Config window shows.
type ConfigState struct {
Open bool `json:"open"`
NeedsConfig bool `json:"needsConfig"`
Error string `json:"error"`
Form ConfigForm `json:"form"`
}
func templateForm() (ConfigForm, error) {
logFile, err := defaultLogPath()
if err != nil {
return ConfigForm{}, err
}
return ConfigForm{CertificateDir: "./funknetz", LogFile: logFile}, nil
}
func parseConfig(path string, body []byte) (configLoad, error) {
var shape fileShape
if err := jsonv2.Unmarshal(body, &shape); err != nil {
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
form, ferr := templateForm()
if ferr != nil {
return configLoad{}, ferr
}
return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil
}
certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString
if block, notice := blockedLog(shape); block || certBad {
cfg, err := parseOperational(shape)
if err != nil {
return loaded{}, err
return refused(shape, err)
}
cfg.logNotice = notice
if !block {
cfg = applyLog(cfg, shape)
}
return applyCertificate(cfg, shape, path), nil
return finish(shape, applyCertificate(cfg, shape, path))
}
if missingCertificate(shape) || missingLog(shape) {
if !operationalKindsOK(shape) {
return parseOperational(shape)
_, err := parseOperational(shape)
if err != nil {
return refused(shape, err)
}
}
var err error
shape, err = withLogDefaults(shape)
if err != nil {
return loaded{}, err
return configLoad{}, err
}
if err := rewriteConfig(path, shape); err != nil {
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
return configLoad{}, fmt.Errorf("the file could not be read: %w", err)
}
}
cfg, err := parseOperational(shape)
if err != nil {
return loaded{}, err
return refused(shape, err)
}
return applyLog(applyCertificate(cfg, shape, path), shape), nil
return finish(shape, applyLog(applyCertificate(cfg, shape, path), shape))
}
func finish(shape fileShape, cfg loaded) (configLoad, error) {
form, err := formFromShape(shape)
if err != nil {
return configLoad{}, err
}
if cfg.certFatal != "" {
return configLoad{cfg: cfg, form: form, detail: cfg.certFatal}, nil
}
return configLoad{cfg: cfg, form: form, loaded: true, detail: cfg.logNotice}, nil
}
func refused(shape fileShape, cause error) (configLoad, error) {
form, err := formFromShape(shape)
if err != nil {
return configLoad{}, err
}
return configLoad{form: form, detail: cause.Error()}, nil
}
func formFromShape(shape fileShape) (ConfigForm, error) {
fallback, err := templateForm()
if err != nil {
fallback = ConfigForm{CertificateDir: "./funknetz"}
}
return mergeSalvage(shape, fallback), nil
}
func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm {
if s, err := stringField("host", shape.Host); err == nil && present(shape.Host) {
fallback.Host = s
}
if present(shape.Port) && shape.Port.Kind() == jsontext.KindNumber {
var n int
if jsonv2.Unmarshal(shape.Port, &n) == nil {
fallback.Port = n
}
}
if s, err := stringField("callsign", shape.Callsign); err == nil && present(shape.Callsign) {
fallback.Callsign = s
}
if s, err := stringField("authKey", shape.AuthKey); err == nil && present(shape.AuthKey) {
fallback.AuthKey = s
}
if present(shape.TalkGroup) && shape.TalkGroup.Kind() == jsontext.KindNumber {
var n int
if jsonv2.Unmarshal(shape.TalkGroup, &n) == nil {
fallback.TalkGroup = n
}
}
if s, err := stringField("certificateDir", shape.CertificateDir); err == nil && present(shape.CertificateDir) {
fallback.CertificateDir = s
}
if present(shape.EnableLogging) && isBool(shape.EnableLogging) {
fallback.EnableLogging = shape.EnableLogging.Kind() == jsontext.KindTrue
}
if s, err := stringField("logFile", shape.LogFile); err == nil && present(shape.LogFile) {
fallback.LogFile = s
}
return fallback
}
func parseOperational(shape fileShape) (loaded, error) {
@@ -304,13 +386,17 @@ func rewriteConfig(path string, shape fileShape) error {
return err
}
body = append(body, '\n')
return rewriteBytes(path, body, info.Mode().Perm())
}
func rewriteBytes(path string, body []byte, mode fs.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".funknetz-*.json")
if err != nil {
return err
}
tmpName := tmp.Name()
_, werr := tmp.Write(body)
cerr := tmp.Chmod(info.Mode().Perm())
cerr := tmp.Chmod(mode)
clerr := tmp.Close()
if werr != nil || cerr != nil || clerr != nil {
os.Remove(tmpName)