From d21e7051d70d28ca0f615f403bb196b2004aeca8 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Sun, 11 Oct 2026 19:29:32 +0200 Subject: [PATCH] Keep certificate-directory problems on the station window and leave a hand-edit untouched until Save. Co-authored-by: Cursor --- app.go | 4 +- frontend/dist/app.css | 7 ++- frontend/dist/app.js | 2 +- station/config.go | 27 ++++---- station/config_test.go | 10 ++- station/config_window.go | 38 ++++++++---- station/config_window_test.go | 114 ++++++++++++++++++++++++++++++++-- station/session.go | 3 +- 8 files changed, 165 insertions(+), 40 deletions(-) diff --git a/app.go b/app.go index 6d69467..0472ac7 100644 --- a/app.go +++ b/app.go @@ -5,7 +5,7 @@ import ( "fmt" "io" - wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/wailsapp/wails/v2/pkg/runtime" "gitea.neitzel.de/konrad/funknetz/audio" "gitea.neitzel.de/konrad/funknetz/station" @@ -67,7 +67,7 @@ func (a *App) CancelConfig() { } quit := a.quit if quit == nil { - quit = wailsruntime.Quit + quit = runtime.Quit } if a.ctx != nil { quit(a.ctx) diff --git a/frontend/dist/app.css b/frontend/dist/app.css index 30ce581..9ac30ba 100644 --- a/frontend/dist/app.css +++ b/frontend/dist/app.css @@ -67,10 +67,13 @@ main { #config { position: fixed; - inset: 0; + top: 64px; + right: 0; + bottom: 0; + left: 0; box-sizing: border-box; padding: 16px; - background: rgba(16, 20, 24, 0.82); + background: rgba(16, 20, 24, 0.45); } #config[hidden] { diff --git a/frontend/dist/app.js b/frontend/dist/app.js index 9f1ceb7..ace9c92 100644 --- a/frontend/dist/app.js +++ b/frontend/dist/app.js @@ -42,7 +42,7 @@ function growWindow() { return; } savedSize = size; - rt.WindowSetSize(Math.max(size.w, 420), configHeight); + rt.WindowSetSize(Math.max(size.w, 420), Math.max(size.h, configHeight)); }); } diff --git a/station/config.go b/station/config.go index 9362461..fb61260 100644 --- a/station/config.go +++ b/station/config.go @@ -73,7 +73,7 @@ func loadConfig(path string) (configLoad, error) { } return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil } - return parseConfig(path, body) + return parseConfig(path, body, true) } // ConfigForm is the eight fields the Config window edits. @@ -104,7 +104,7 @@ func templateForm() (ConfigForm, error) { return ConfigForm{CertificateDir: "./funknetz", LogFile: logFile}, nil } -func parseConfig(path string, body []byte) (configLoad, error) { +func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) { var shape fileShape if err := jsonv2.Unmarshal(body, &shape); err != nil { form, ferr := templateForm() @@ -137,8 +137,10 @@ func parseConfig(path string, body []byte) (configLoad, error) { if err != nil { return configLoad{}, err } - if err := rewriteConfig(path, shape); err != nil { - return configLoad{}, fmt.Errorf("the file could not be read: %w", err) + if rewrite { + if err := rewriteConfig(path, shape); err != nil { + return configLoad{}, fmt.Errorf("the file could not be read: %w", err) + } } } cfg, err := parseOperational(shape) @@ -149,30 +151,23 @@ func parseConfig(path string, body []byte) (configLoad, error) { } func finish(shape fileShape, cfg loaded) (configLoad, error) { - form, err := formFromShape(shape) - if err != nil { - return configLoad{}, err - } + form := formFromShape(shape) if cfg.certFatal != "" { - return configLoad{cfg: cfg, form: form, detail: cfg.certFatal}, nil + return configLoad{cfg: cfg, form: form, loaded: true, 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 + return configLoad{form: formFromShape(shape), detail: cause.Error()}, nil } -func formFromShape(shape fileShape) (ConfigForm, error) { +func formFromShape(shape fileShape) ConfigForm { fallback, err := templateForm() if err != nil { fallback = ConfigForm{CertificateDir: "./funknetz"} } - return mergeSalvage(shape, fallback), nil + return mergeSalvage(shape, fallback) } func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm { diff --git a/station/config_test.go b/station/config_test.go index 77a84c0..ec87e0e 100644 --- a/station/config_test.go +++ b/station/config_test.go @@ -446,7 +446,10 @@ func TestInvalidSaveWritesNothingAndDoesNotConnect(t *testing.T) { t.Cleanup(st.Close) ctx, cancel := context.WithCancel(t.Context()) defer cancel() - go func() { _ = st.Run(ctx) }() + go func() { + // Run's error is observed through State, which is the station seam. + _ = st.Run(ctx) + }() err = st.Save(station.ConfigForm{ Host: "", @@ -496,7 +499,10 @@ func TestValidSaveCreatesFileAndConnects(t *testing.T) { t.Cleanup(st.Close) ctx, cancel := context.WithCancel(t.Context()) defer cancel() - go func() { _ = st.Run(ctx) }() + go func() { + // Run's error is observed through State, which is the station seam. + _ = st.Run(ctx) + }() err = st.Save(station.ConfigForm{ Host: " 127.0.0.1 ", diff --git a/station/config_window.go b/station/config_window.go index abdb41c..af18803 100644 --- a/station/config_window.go +++ b/station/config_window.go @@ -75,8 +75,19 @@ func (s *Station) Save(form ConfigForm) error { s.logPath = next.logPath s.logNotice = next.logNotice reconnect := wasLoaded && connectionChanged(prev, next) - if reconnect || !wasLoaded { - s.fatal = "" + 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() @@ -98,7 +109,8 @@ func (s *Station) kickReconnect() { s.cancelDial() } if s.conn != nil { - s.conn.Close() + // Close unblocks the session read so Run can dial again. + _ = s.conn.Close() } select { case s.apply <- struct{}{}: @@ -139,7 +151,7 @@ func readForm(path string) (ConfigForm, string, bool) { } return form, fmt.Errorf("the file could not be read: %w", err).Error(), false } - out, err := parseConfig(path, body) + out, err := parseConfig(path, body, false) if err != nil { return out.form, err.Error(), false } @@ -170,9 +182,6 @@ func validateForm(configPath string, form ConfigForm) (loaded, ConfigForm, error 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, @@ -272,11 +281,16 @@ func createExclusive(path string, body []byte) error { } _, werr := f.Write(body) cerr := f.Close() - if werr != nil { - return werr - } - if cerr != nil { + if werr != nil || cerr != nil { + os.Remove(path) + if werr != nil { + return werr + } return cerr } - return os.Chmod(path, 0o600) + if err := os.Chmod(path, 0o600); err != nil { + os.Remove(path) + return err + } + return nil } diff --git a/station/config_window_test.go b/station/config_window_test.go index 2fa996f..394d0c2 100644 --- a/station/config_window_test.go +++ b/station/config_window_test.go @@ -211,6 +211,107 @@ func TestConfigStaysClosedWhilePushToTalkIsHeld(t *testing.T) { } } +func TestCertificateProblemStaysOnTheStation(t *testing.T) { + path := filepath.Join(t.TempDir(), "funknetz.json") + body := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"certificateDir\":\"\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + st, err := station.New(station.Options{ConfigPath: path}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + got := st.State() + if got.ConfigOpen || got.NeedsConfig || got.Detail != "certificateDir is empty" || got.Connection != "Disconnected" { + t.Fatalf("State = %+v, want the station window with the certificate detail", got) + } + if st.Cancel() { + t.Fatal("Cancel() quit after a certificate-directory problem") + } +} + +func TestOpenConfigDoesNotRewriteAHandEdit(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "funknetz.json") + body := []byte(fullConfig("127.0.0.1", 5308, "DL1ABC", "key", 20, filepath.Join(dir, "funknetz.log"))) + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + st, err := station.New(station.Options{ConfigPath: path}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + edited := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"" + filepath.Join(dir, "funknetz.log") + "\"}\n") + if err := os.WriteFile(path, edited, 0o600); err != nil { + t.Fatal(err) + } + st.OpenConfig() + if st.Cancel() { + t.Fatal("Cancel() quit") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(edited) { + t.Fatalf("file = %s, want the hand-edit left unchanged", got) + } + if st.State().Callsign != "DL1ABC" || st.State().ConfigOpen { + t.Fatalf("State = %+v, want the loaded session and a closed Config window", st.State()) + } +} + +func TestSaveEmptyCertificateDirDoesNotConnect(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + path := filepath.Join(t.TempDir(), "funknetz.json") + st, err := station.New(station.Options{ConfigPath: path}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go func() { + // Run's error is observed through State, which is the station seam. + _ = st.Run(ctx) + }() + err = st.Save(station.ConfigForm{ + Host: "127.0.0.1", + Port: port, + Callsign: "DL1ABC", + AuthKey: "key", + TalkGroup: 20, + CertificateDir: "", + LogFile: "/tmp/funknetz.log", + }) + if err != nil { + t.Fatal(err) + } + got := st.State() + if got.ConfigOpen || got.Detail != "certificateDir is empty" || got.Connection != "Disconnected" { + t.Fatalf("State = %+v, want the window closed and the certificate detail", got) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %o, want 600", info.Mode().Perm()) + } + ln.(*net.TCPListener).SetDeadline(time.Now().Add(200 * time.Millisecond)) + if conn, err := ln.Accept(); err == nil { + conn.Close() + t.Fatal("opened a socket") + } +} + func TestLoggingOnlySaveKeepsTheSession(t *testing.T) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) logPath := filepath.Join(t.TempDir(), "station.log") @@ -253,7 +354,10 @@ func TestLoggingSaveDoesNotClearAFatalStop(t *testing.T) { st.Fail("could not open the audio device") ctx, cancel := context.WithCancel(t.Context()) defer cancel() - go func() { _ = st.Run(ctx) }() + go func() { + // Run's error is observed through State, which is the station seam. + _ = st.Run(ctx) + }() st.OpenConfig() form := st.Config().Form @@ -297,7 +401,10 @@ func TestSaveWriteFailureKeepsTheSession(t *testing.T) { if err := os.Chmod(dir, 0o555); err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + t.Cleanup(func() { + // Restore the directory so the test temp dir can be removed. + _ = os.Chmod(dir, 0o755) + }) in.st.OpenConfig() form := in.st.Config().Form form.TalkGroup = 21 @@ -393,8 +500,7 @@ func connStillOpen(conn net.Conn) error { if err == nil { return nil } - var ne net.Error - if errors.As(err, &ne) && ne.Timeout() { + if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() { return nil } return err diff --git a/station/session.go b/station/session.go index 84de51f..77d1d9b 100644 --- a/station/session.go +++ b/station/session.go @@ -62,7 +62,8 @@ func (s *Station) Run(ctx context.Context) error { s.mu.Unlock() if stale { if conn != nil { - conn.Close() + // This dial used the config from before Save. + _ = conn.Close() } s.drainApply() continue