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 }