Keep certificate-directory problems on the station window and leave a hand-edit untouched until Save.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-11 19:29:32 +02:00
co-authored by Cursor
parent 775d1b5c82
commit d21e7051d7
8 changed files with 165 additions and 40 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"io" "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/audio"
"gitea.neitzel.de/konrad/funknetz/station" "gitea.neitzel.de/konrad/funknetz/station"
@@ -67,7 +67,7 @@ func (a *App) CancelConfig() {
} }
quit := a.quit quit := a.quit
if quit == nil { if quit == nil {
quit = wailsruntime.Quit quit = runtime.Quit
} }
if a.ctx != nil { if a.ctx != nil {
quit(a.ctx) quit(a.ctx)
+5 -2
View File
@@ -67,10 +67,13 @@ main {
#config { #config {
position: fixed; position: fixed;
inset: 0; top: 64px;
right: 0;
bottom: 0;
left: 0;
box-sizing: border-box; box-sizing: border-box;
padding: 16px; padding: 16px;
background: rgba(16, 20, 24, 0.82); background: rgba(16, 20, 24, 0.45);
} }
#config[hidden] { #config[hidden] {
+1 -1
View File
@@ -42,7 +42,7 @@ function growWindow() {
return; return;
} }
savedSize = size; savedSize = size;
rt.WindowSetSize(Math.max(size.w, 420), configHeight); rt.WindowSetSize(Math.max(size.w, 420), Math.max(size.h, configHeight));
}); });
} }
+9 -14
View File
@@ -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 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. // ConfigForm is the eight fields the Config window edits.
@@ -104,7 +104,7 @@ func templateForm() (ConfigForm, error) {
return ConfigForm{CertificateDir: "./funknetz", LogFile: logFile}, nil 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 var shape fileShape
if err := jsonv2.Unmarshal(body, &shape); err != nil { if err := jsonv2.Unmarshal(body, &shape); err != nil {
form, ferr := templateForm() form, ferr := templateForm()
@@ -137,10 +137,12 @@ func parseConfig(path string, body []byte) (configLoad, error) {
if err != nil { if err != nil {
return configLoad{}, err return configLoad{}, err
} }
if rewrite {
if err := rewriteConfig(path, shape); err != nil { if err := rewriteConfig(path, shape); err != nil {
return configLoad{}, 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) cfg, err := parseOperational(shape)
if err != nil { if err != nil {
return refused(shape, err) return refused(shape, err)
@@ -149,30 +151,23 @@ func parseConfig(path string, body []byte) (configLoad, error) {
} }
func finish(shape fileShape, cfg loaded) (configLoad, error) { func finish(shape fileShape, cfg loaded) (configLoad, error) {
form, err := formFromShape(shape) form := formFromShape(shape)
if err != nil {
return configLoad{}, err
}
if cfg.certFatal != "" { 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 return configLoad{cfg: cfg, form: form, loaded: true, detail: cfg.logNotice}, nil
} }
func refused(shape fileShape, cause error) (configLoad, error) { func refused(shape fileShape, cause error) (configLoad, error) {
form, err := formFromShape(shape) return configLoad{form: formFromShape(shape), detail: cause.Error()}, nil
if err != nil {
return configLoad{}, err
}
return configLoad{form: form, detail: cause.Error()}, nil
} }
func formFromShape(shape fileShape) (ConfigForm, error) { func formFromShape(shape fileShape) ConfigForm {
fallback, err := templateForm() fallback, err := templateForm()
if err != nil { if err != nil {
fallback = ConfigForm{CertificateDir: "./funknetz"} fallback = ConfigForm{CertificateDir: "./funknetz"}
} }
return mergeSalvage(shape, fallback), nil return mergeSalvage(shape, fallback)
} }
func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm { func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm {
+8 -2
View File
@@ -446,7 +446,10 @@ func TestInvalidSaveWritesNothingAndDoesNotConnect(t *testing.T) {
t.Cleanup(st.Close) t.Cleanup(st.Close)
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() 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{ err = st.Save(station.ConfigForm{
Host: "", Host: "",
@@ -496,7 +499,10 @@ func TestValidSaveCreatesFileAndConnects(t *testing.T) {
t.Cleanup(st.Close) t.Cleanup(st.Close)
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() 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{ err = st.Save(station.ConfigForm{
Host: " 127.0.0.1 ", Host: " 127.0.0.1 ",
+22 -8
View File
@@ -75,8 +75,19 @@ func (s *Station) Save(form ConfigForm) error {
s.logPath = next.logPath s.logPath = next.logPath
s.logNotice = next.logNotice s.logNotice = next.logNotice
reconnect := wasLoaded && connectionChanged(prev, next) reconnect := wasLoaded && connectionChanged(prev, next)
if reconnect || !wasLoaded { 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.fatal = ""
}
s.closeLogLocked() s.closeLogLocked()
if next.enableLogging { if next.enableLogging {
s.openLog() s.openLog()
@@ -98,7 +109,8 @@ func (s *Station) kickReconnect() {
s.cancelDial() s.cancelDial()
} }
if s.conn != nil { if s.conn != nil {
s.conn.Close() // Close unblocks the session read so Run can dial again.
_ = s.conn.Close()
} }
select { select {
case s.apply <- struct{}{}: 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 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 { if err != nil {
return out.form, err.Error(), false return out.form, err.Error(), false
} }
@@ -170,9 +182,6 @@ func validateForm(configPath string, form ConfigForm) (loaded, ConfigForm, error
return loaded{}, form, err return loaded{}, form, err
} }
cfg = applyCertificate(cfg, shape, configPath) cfg = applyCertificate(cfg, shape, configPath)
if cfg.certFatal != "" {
return loaded{}, form, errors.New(cfg.certFatal)
}
cfg = applyLog(cfg, shape) cfg = applyLog(cfg, shape)
stored := ConfigForm{ stored := ConfigForm{
Host: cfg.host, Host: cfg.host,
@@ -272,11 +281,16 @@ func createExclusive(path string, body []byte) error {
} }
_, werr := f.Write(body) _, werr := f.Write(body)
cerr := f.Close() cerr := f.Close()
if werr != nil || cerr != nil {
os.Remove(path)
if werr != nil { if werr != nil {
return werr return werr
} }
if cerr != nil {
return cerr return cerr
} }
return os.Chmod(path, 0o600) if err := os.Chmod(path, 0o600); err != nil {
os.Remove(path)
return err
}
return nil
} }
+110 -4
View File
@@ -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) { func TestLoggingOnlySaveKeepsTheSession(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
logPath := filepath.Join(t.TempDir(), "station.log") logPath := filepath.Join(t.TempDir(), "station.log")
@@ -253,7 +354,10 @@ func TestLoggingSaveDoesNotClearAFatalStop(t *testing.T) {
st.Fail("could not open the audio device") st.Fail("could not open the audio device")
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() 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() st.OpenConfig()
form := st.Config().Form form := st.Config().Form
@@ -297,7 +401,10 @@ func TestSaveWriteFailureKeepsTheSession(t *testing.T) {
if err := os.Chmod(dir, 0o555); err != nil { if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err) 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() in.st.OpenConfig()
form := in.st.Config().Form form := in.st.Config().Form
form.TalkGroup = 21 form.TalkGroup = 21
@@ -393,8 +500,7 @@ func connStillOpen(conn net.Conn) error {
if err == nil { if err == nil {
return nil return nil
} }
var ne net.Error if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() {
if errors.As(err, &ne) && ne.Timeout() {
return nil return nil
} }
return err return err
+2 -1
View File
@@ -62,7 +62,8 @@ func (s *Station) Run(ctx context.Context) error {
s.mu.Unlock() s.mu.Unlock()
if stale { if stale {
if conn != nil { if conn != nil {
conn.Close() // This dial used the config from before Save.
_ = conn.Close()
} }
s.drainApply() s.drainApply()
continue continue