diff --git a/app.go b/app.go index 18a10aa..6d69467 100644 --- a/app.go +++ b/app.go @@ -5,17 +5,22 @@ import ( "fmt" "io" + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" + "gitea.neitzel.de/konrad/funknetz/audio" "gitea.neitzel.de/konrad/funknetz/station" ) // App is the window's view of the station. type App struct { + ctx context.Context cancel context.CancelFunc station *station.Station + quit func(context.Context) } func (a *App) startup(ctx context.Context) { + a.ctx = ctx runCtx, cancel := context.WithCancel(ctx) a.cancel = cancel if err := audio.Start(runCtx, a.station.WriteMicrophone, a.station.TakePlayback); err != nil { @@ -46,6 +51,29 @@ func (a *App) HoldSpace() { a.station.HoldSpace() } // ReleaseSpace reports that Space was released. func (a *App) ReleaseSpace() { a.station.ReleaseSpace() } +// Config returns the Config window form. +func (a *App) Config() station.ConfigState { return a.station.Config() } + +// OpenConfig opens the Config window, re-reading the file. +func (a *App) OpenConfig() { a.station.OpenConfig() } + +// Save validates and writes the config form. +func (a *App) Save(form station.ConfigForm) error { return a.station.Save(form) } + +// CancelConfig discards the form. When no config has loaded, it quits. +func (a *App) CancelConfig() { + if !a.station.Cancel() { + return + } + quit := a.quit + if quit == nil { + quit = wailsruntime.Quit + } + if a.ctx != nil { + quit(a.ctx) + } +} + func openStation(args []string) (*station.Station, error) { path, err := resolveConfigPath(args) if err != nil { @@ -54,8 +82,9 @@ func openStation(args []string) (*station.Station, error) { return station.New(station.Options{ConfigPath: path}) } -// openStationReported opens the station from args. A flag or config failure is -// one line on stderr and the returned error. The window is not opened. +// openStationReported opens the station from args. A flag or path failure is +// one line on stderr and the returned error. A missing or invalid config still +// returns a station so the window can open. func openStationReported(args []string, stderr io.Writer) (*station.Station, error) { st, err := openStation(args) if err != nil { diff --git a/config_path_test.go b/config_path_test.go index 1944142..b070163 100644 --- a/config_path_test.go +++ b/config_path_test.go @@ -4,12 +4,11 @@ package main import ( "bytes" + "context" "os" "path/filepath" "strings" "testing" - - "encoding/json/v2" ) func TestResolveConfigPathDefaultIsHome(t *testing.T) { @@ -86,20 +85,15 @@ func TestOpenStationUsesResolvedPath(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "custom.json") - _, err := openStation([]string{"-c", path}) - if err == nil { - t.Fatal("openStation() error = nil, want rejection after creating the file") - } - if !strings.Contains(err.Error(), "created") { - t.Fatalf("openStation() error = %q, want it to say the file was created", err.Error()) - } - - info, err := os.Stat(path) + st, err := openStation([]string{"-c", path}) if err != nil { t.Fatal(err) } - if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { - t.Fatalf("mode = %o, want %o", got, want) + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("stat = %v, want the file absent until Save", statErr) + } + if !st.State().NeedsConfig || !st.State().ConfigOpen { + t.Fatalf("State = %+v, want the Config window open", st.State()) } // Same rules as the home file: a second open with a filled config works. @@ -116,7 +110,7 @@ func TestOpenStationUsesResolvedPath(t *testing.T) { if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } - st, err := openStation([]string{"--config", path}) + st, err = openStation([]string{"--config", path}) if err != nil { t.Fatal(err) } @@ -136,52 +130,35 @@ func TestConfigEqualsFlagSelectsTheFile(t *testing.T) { } } -func TestCreatedFileViaFlagMatchesHomeTemplate(t *testing.T) { +func TestMissingFileOpensWithoutStderr(t *testing.T) { path := filepath.Join(t.TempDir(), "lab.json") var stderr bytes.Buffer st, err := openStationReported([]string{"-c", path}, &stderr) - if st != nil { - t.Fatal("station = non-nil, want nil before the window") - } - if err == nil { - t.Fatal("error = nil, want the file created") - } - if got, want := err.Error(), "created "+path; got != want { - t.Fatalf("error = %q, want %q", got, want) - } - if got, want := stderr.String(), err.Error()+"\n"; got != want { - t.Fatalf("stderr = %q, want %q", got, want) - } - info, err := os.Stat(path) if err != nil { t.Fatal(err) } - if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { - t.Fatalf("mode = %o, want %o", got, want) + if st == nil { + t.Fatal("station = nil, want a station so the window can open") } - body, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) } - var got map[string]any - if err := json.Unmarshal(body, &got); err != nil { - t.Fatalf("created file is not JSON: %v\n%s", err, body) + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("stat = %v, want the file absent", statErr) } - if got["host"] != "" || got["callsign"] != "" || got["authKey"] != "" { - t.Fatalf("string fields = %#v, want empty host, callsign, and authKey", got) + if !st.State().NeedsConfig || !st.State().ConfigOpen { + t.Fatalf("State = %+v, want the Config window open", st.State()) } - if got["port"] != float64(0) || got["talkGroup"] != float64(0) { - t.Fatalf("numeric fields = %#v, want port and talkGroup at zero", got) - } - if got["enableLogging"] != false { - t.Fatalf("enableLogging = %#v, want false", got["enableLogging"]) + form := st.Config().Form + if form.Host != "" || form.Callsign != "" || form.AuthKey != "" || form.Port != 0 || form.TalkGroup != 0 || form.EnableLogging || form.CertificateDir != "./funknetz" { + t.Fatalf("Form = %+v, want template defaults", form) } home, err := os.UserHomeDir() if err != nil { t.Fatal(err) } - if got["logFile"] != filepath.Join(home, "funknetz.log") { - t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"]) + if form.LogFile != filepath.Join(home, "funknetz.log") { + t.Fatalf("LogFile = %q, want funknetz.log in the home directory", form.LogFile) } } @@ -271,7 +248,7 @@ func TestExtraWordsDoNotChangeTheSelectedFile(t *testing.T) { func TestSelectedFileIsLeftByteForByte(t *testing.T) { path := filepath.Join(t.TempDir(), "lab.json") - original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") + original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") if err := os.WriteFile(path, original, 0o640); err != nil { t.Fatal(err) } @@ -292,7 +269,7 @@ func TestSelectedFileIsLeftByteForByte(t *testing.T) { } } -func TestEmptyHostOnSelectedFileNamesHost(t *testing.T) { +func TestEmptyHostOnSelectedFileOpensConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "lab.json") body := []byte("{\"host\":\"\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") if err := os.WriteFile(path, body, 0o600); err != nil { @@ -300,14 +277,64 @@ func TestEmptyHostOnSelectedFileNamesHost(t *testing.T) { } var stderr bytes.Buffer st, err := openStationReported([]string{"-c", path}, &stderr) - if st != nil { - t.Fatal("station = non-nil, want nil") + if err != nil { + t.Fatal(err) } - if err == nil || !strings.Contains(err.Error(), "host") { - t.Fatalf("error = %v, want it to name host", err) + if st == nil || !st.State().ConfigOpen || !strings.Contains(st.State().Detail, "host") { + t.Fatalf("State = %+v, want the Config window open naming host", st.State()) } - if stderr.String() != err.Error()+"\n" { - t.Fatalf("stderr = %q, want the error line", stderr.String()) + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestCancelWithoutALoadedConfigQuits(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.json") + st, err := openStation([]string{"-c", path}) + if err != nil { + t.Fatal(err) + } + quit := false + app := &App{ + station: st, + ctx: t.Context(), + quit: func(context.Context) { quit = true }, + } + app.CancelConfig() + if !quit { + t.Fatal("CancelConfig() left the app running") + } +} + +func TestCancelAfterLoadKeepsTheSession(t *testing.T) { + path := filepath.Join(t.TempDir(), "lab.json") + body := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + st, err := openStation([]string{"-c", path}) + if err != nil { + t.Fatal(err) + } + quit := false + app := &App{ + station: st, + ctx: t.Context(), + quit: func(context.Context) { quit = true }, + } + app.CancelConfig() + if quit { + t.Fatal("CancelConfig() quit after a loaded config") + } + if st.State().ConfigOpen { + t.Fatal("Config window stayed open") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(body) { + t.Fatalf("file = %s, want it left unchanged", got) } } diff --git a/frontend/dist/app.css b/frontend/dist/app.css index bd688bc..30ce581 100644 --- a/frontend/dist/app.css +++ b/frontend/dist/app.css @@ -9,11 +9,31 @@ main { padding: 24px; } -#connection { +.status { + display: flex; + align-items: center; + justify-content: space-between; margin: 0 0 16px; +} + +#connection { + margin: 0; font-size: 14px; } +#gear { + width: 32px; + height: 32px; + margin: 0; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: #f2f4f6; + font-size: 20px; + line-height: 32px; +} + #protocol { min-height: 1.4em; margin: 0; @@ -24,7 +44,7 @@ main { font-size: 20px; } -button { +#ptt { margin-top: 24px; width: 100%; height: 64px; @@ -36,6 +56,115 @@ button { font-size: 18px; } -button.down { +#ptt.down { background: #9b2226; } + +#ptt:disabled { + background: #3a4248; + color: #9aa3ab; +} + +#config { + position: fixed; + inset: 0; + box-sizing: border-box; + padding: 16px; + background: rgba(16, 20, 24, 0.82); +} + +#config[hidden] { + display: none; +} + +#config form { + box-sizing: border-box; + width: 100%; + margin: 0; + padding: 16px; + border: 1px solid #2c3640; + border-radius: 8px; + background: #101418; +} + +#config h1 { + margin: 0 0 12px; + font-size: 18px; +} + +#config-error { + margin: 0 0 12px; + color: #f4a261; +} + +#config label { + display: block; + margin: 0 0 10px; + font-size: 14px; +} + +#config input { + box-sizing: border-box; + display: block; + width: 100%; + margin-top: 4px; + padding: 8px; + border: 1px solid #2c3640; + border-radius: 6px; + background: #0c1014; + color: inherit; + font: inherit; +} + +#config .check input { + display: inline-block; + width: auto; + margin: 0 0 0 8px; + vertical-align: middle; +} + +.secret { + display: flex; + gap: 8px; + align-items: center; +} + +.secret input { + flex: 1; +} + +#auth-toggle, +#cancel, +#save { + height: 40px; + margin: 0; + border: 0; + border-radius: 6px; + font: inherit; +} + +#auth-toggle { + width: 72px; + background: #243038; + color: inherit; +} + +.actions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.actions button { + flex: 1; +} + +#cancel { + background: #243038; + color: inherit; +} + +#save { + background: #2d6a4f; + color: white; +} diff --git a/frontend/dist/app.js b/frontend/dist/app.js index 692b40e..9f1ceb7 100644 --- a/frontend/dist/app.js +++ b/frontend/dist/app.js @@ -1,4 +1,14 @@ const button = document.getElementById("ptt"); +const config = document.getElementById("config"); +const configForm = document.getElementById("config-form"); +const configError = document.getElementById("config-error"); +const authKey = document.getElementById("authKey"); +const authToggle = document.getElementById("auth-toggle"); + +const configHeight = 680; +let savedSize = null; +let configLoading = false; +let sizeToken = 0; function api() { if (!window.go || !window.go.main || !window.go.main.App) { @@ -17,6 +27,104 @@ function line(state) { return "Idle"; } +function runtime() { + return window.runtime || null; +} + +function growWindow() { + const rt = runtime(); + if (!rt || !rt.WindowGetSize || !rt.WindowSetSize) { + return; + } + const token = ++sizeToken; + rt.WindowGetSize().then(function (size) { + if (token !== sizeToken || config.hidden) { + return; + } + savedSize = size; + rt.WindowSetSize(Math.max(size.w, 420), configHeight); + }); +} + +function restoreWindow() { + sizeToken++; + const rt = runtime(); + if (!savedSize || !rt || !rt.WindowSetSize) { + savedSize = null; + return; + } + rt.WindowSetSize(savedSize.w, savedSize.h); + savedSize = null; +} + +function showError(message) { + configError.hidden = !message; + configError.textContent = message || ""; +} + +function fillForm(form) { + document.getElementById("host").value = form.host || ""; + document.getElementById("port").value = String(form.port || 0); + document.getElementById("callsign-field").value = form.callsign || ""; + authKey.value = form.authKey || ""; + document.getElementById("talkGroup").value = String(form.talkGroup || 0); + document.getElementById("certificateDir").value = form.certificateDir || ""; + document.getElementById("enableLogging").checked = !!form.enableLogging; + document.getElementById("logFile").value = form.logFile || ""; + authKey.type = "password"; + authToggle.textContent = "Show"; +} + +function readForm() { + return { + host: document.getElementById("host").value, + port: numberValue("port"), + callsign: document.getElementById("callsign-field").value, + authKey: authKey.value, + talkGroup: numberValue("talkGroup"), + certificateDir: document.getElementById("certificateDir").value, + enableLogging: document.getElementById("enableLogging").checked, + logFile: document.getElementById("logFile").value + }; +} + +function numberValue(id) { + const text = document.getElementById(id).value.trim(); + if (text === "") { + return 0; + } + const n = Number(text); + if (!Number.isInteger(n)) { + return 0; + } + return n; +} + +function showConfig() { + const app = api(); + if (!app || configLoading) { + return; + } + configLoading = true; + app.Config().then(function (cfg) { + if (!cfg.open) { + return; + } + fillForm(cfg.form || {}); + showError(cfg.error); + config.hidden = false; + growWindow(); + }).catch(function () {}).then(function () { + configLoading = false; + }); +} + +function hideConfig() { + config.hidden = true; + showError(""); + restoreWindow(); +} + function render(state) { document.getElementById("connection").textContent = state.connection; document.getElementById("callsign").textContent = state.callsign; @@ -25,6 +133,12 @@ function render(state) { protocol.textContent = state.protocol ? "Protocol " + state.protocol : ""; document.getElementById("detail").textContent = line(state); button.classList.toggle("down", !!state.transmitting); + button.disabled = !!state.configOpen; + if (state.configOpen && config.hidden) { + showConfig(); + } else if (!state.configOpen && !config.hidden) { + hideConfig(); + } } function refresh() { @@ -35,7 +149,47 @@ function refresh() { app.State().then(render).catch(function () {}); } +function cancelConfig() { + const app = api(); + if (app) { + app.CancelConfig(); + } +} + +document.getElementById("gear").addEventListener("click", function () { + const app = api(); + if (app) { + app.OpenConfig(); + } +}); + +configForm.addEventListener("submit", function (event) { + event.preventDefault(); + const app = api(); + if (!app) { + return; + } + app.Save(readForm()).then(function () { + refresh(); + }).catch(function () { + app.Config().then(function (cfg) { + showError(cfg.error); + }).catch(function () {}); + }); +}); + +document.getElementById("cancel").addEventListener("click", cancelConfig); + +authToggle.addEventListener("click", function () { + const show = authKey.type === "password"; + authKey.type = show ? "text" : "password"; + authToggle.textContent = show ? "Hide" : "Show"; +}); + button.addEventListener("pointerdown", function (event) { + if (button.disabled) { + return; + } event.preventDefault(); button.setPointerCapture(event.pointerId); const app = api(); @@ -57,11 +211,18 @@ button.addEventListener("pointercancel", releaseButton); let spaceHeld = false; window.addEventListener("keydown", function (event) { + if (event.code === "Escape") { + if (!config.hidden) { + event.preventDefault(); + cancelConfig(); + } + return; + } if (event.code !== "Space") { return; } event.preventDefault(); - if (event.repeat || spaceHeld) { + if (!config.hidden || button.disabled || event.repeat || spaceHeld) { return; } spaceHeld = true; diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 307e3b9..a2e12c4 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -7,13 +7,39 @@
-

Disconnected

+
+

Disconnected

+ +

Callsign

Talk group

Idle

+ diff --git a/station/behavior_test.go b/station/behavior_test.go index eb9663e..ea23318 100644 --- a/station/behavior_test.go +++ b/station/behavior_test.go @@ -21,6 +21,7 @@ type loggedIn struct { refl *reflector cancel context.CancelFunc id uint16 + path string } func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []string) loggedIn { @@ -94,7 +95,7 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st if got.Protocol != "2.0" { t.Fatalf("Protocol = %q, want 2.0", got.Protocol) } - return loggedIn{st: st, clock: clock, refl: refl, cancel: cancel, id: id} + return loggedIn{st: st, clock: clock, refl: refl, cancel: cancel, id: id, path: path} } func declineTo2(t *testing.T, refl *reflector) { diff --git a/station/config.go b/station/config.go index e86a272..9362461 100644 --- a/station/config.go +++ b/station/config.go @@ -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) diff --git a/station/config_test.go b/station/config_test.go index e33614b..77a84c0 100644 --- a/station/config_test.go +++ b/station/config_test.go @@ -15,53 +15,46 @@ import ( "gitea.neitzel.de/konrad/funknetz/station" ) -func TestMissingConfigIsCreatedAndRejected(t *testing.T) { +func TestMissingConfigOpensWithoutCreating(t *testing.T) { path := filepath.Join(t.TempDir(), ".funknetz.json") - _, err := station.New(station.Options{ConfigPath: path}) - if err == nil { - t.Fatal("New() error = nil, want a rejection after creating the file") - } - if !strings.Contains(err.Error(), "created") { - t.Fatalf("New() error = %q, want it to say the file was created", err.Error()) - } - - info, err := os.Stat(path) + st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) } - if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { - t.Fatalf("mode = %o, want %o", got, want) + t.Cleanup(st.Close) + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("stat = %v, want the file absent", statErr) } - body, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) + got := st.State() + if !got.NeedsConfig || !got.ConfigOpen || got.Connection != "Disconnected" { + t.Fatalf("State = %+v, want Disconnected with the Config window open", got) } - var got map[string]any - if err := jsonv2.Unmarshal(body, &got); err != nil { - t.Fatalf("created file is not JSON: %v\n%s", err, body) - } - if got["host"] != "" || got["callsign"] != "" || got["authKey"] != "" { - t.Fatalf("string fields = %#v, want empty host, callsign, and authKey", got) - } - if got["port"] != float64(0) || got["talkGroup"] != float64(0) { - t.Fatalf("numeric fields = %#v, want port and talkGroup at zero", got) + cfg := st.Config() + if !cfg.Open || !cfg.NeedsConfig || cfg.Error != "" { + t.Fatalf("Config = %+v, want an open needs-config form and no error", cfg) } home, err := os.UserHomeDir() if err != nil { t.Fatal(err) } - if got["enableLogging"] != false { - t.Fatalf("enableLogging = %#v, want false", got["enableLogging"]) + form := cfg.Form + if form.Host != "" || form.Callsign != "" || form.AuthKey != "" { + t.Fatalf("string fields = %+v, want empty host, callsign, and authKey", form) } - if got["logFile"] != filepath.Join(home, "funknetz.log") { - t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"]) + if form.Port != 0 || form.TalkGroup != 0 { + t.Fatalf("numeric fields = %+v, want port and talkGroup at zero", form) } - if got["certificateDir"] != "./funknetz" { - t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"]) + if form.EnableLogging { + t.Fatal("EnableLogging = true, want false") + } + if form.CertificateDir != "./funknetz" { + t.Fatalf("CertificateDir = %q, want ./funknetz", form.CertificateDir) + } + if form.LogFile != filepath.Join(home, "funknetz.log") { + t.Fatalf("LogFile = %q, want funknetz.log in the home directory", form.LogFile) } - assertKeyOrder(t, body) } func TestExistingConfigIsNotRewritten(t *testing.T) { @@ -133,12 +126,17 @@ func TestInvalidConfigNamesTheField(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = station.New(station.Options{ConfigPath: path}) - if err == nil { - t.Fatal("New() error = nil, want a field rejection") + st, err := station.New(station.Options{ConfigPath: path}) + if err != nil { + t.Fatal(err) } - if err.Error() != tt.want { - t.Fatalf("New() error = %q, want %q", err.Error(), tt.want) + t.Cleanup(st.Close) + state := st.State() + if !state.NeedsConfig || !state.ConfigOpen || state.Detail != tt.want { + t.Fatalf("State = %+v, want the Config window open with detail %q", state, tt.want) + } + if st.Config().Error != tt.want { + t.Fatalf("Config error = %q, want %q", st.Config().Error, tt.want) } after, err := os.ReadFile(path) if err != nil { @@ -164,20 +162,55 @@ func TestInvalidConfigNamesTheField(t *testing.T) { } } +func TestBrokenFileSalvagesTheOtherFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "funknetz.json") + body := `{"host":1,"port":5308,"callsign":"DL1ABC","authKey":"a b","talkGroup":20,"certificateDir":"./radios","enableLogging":true,"logFile":"/tmp/funknetz.log"}` + if err := os.WriteFile(path, []byte(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) + form := st.Config().Form + if form.Host != "" || form.Port != 5308 || form.Callsign != "DL1ABC" || form.AuthKey != "a b" || form.TalkGroup != 20 { + t.Fatalf("Form = %+v, want the valid fields kept and host cleared", form) + } + if form.CertificateDir != "./radios" || !form.EnableLogging || form.LogFile != "/tmp/funknetz.log" { + t.Fatalf("Form = %+v, want certificate, logging, and log file kept", form) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != body { + t.Fatalf("file = %s, want it left untouched", after) + } +} + func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { t.Fatal(err) } - _, err := station.New(station.Options{ConfigPath: path}) - if err == nil { - t.Fatal("New() error = nil, want a read failure") + st, err := station.New(station.Options{ConfigPath: path}) + if err != nil { + t.Fatal(err) } - if !strings.Contains(err.Error(), "the file could not be read") { - t.Fatalf("New() error = %q, want it to say the file could not be read", err.Error()) + t.Cleanup(st.Close) + got := st.Config() + if !got.Open || !got.NeedsConfig { + t.Fatalf("Config = %+v, want the Config window open", got) } - if strings.Contains(err.Error(), "callsign") { - t.Fatalf("New() error = %q, want no field name", err.Error()) + if !strings.Contains(got.Error, "the file could not be read") { + t.Fatalf("Config error = %q, want it to say the file could not be read", got.Error) + } + if strings.Contains(got.Error, "callsign") { + t.Fatalf("Config error = %q, want no field name", got.Error) + } + if got.Form.Host != "" || got.Form.Callsign != "" || got.Form.Port != 0 || got.Form.CertificateDir != "./funknetz" { + t.Fatalf("Form = %+v, want template defaults", got.Form) } } @@ -360,15 +393,20 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) { }) } - t.Run("bad host still quits", func(t *testing.T) { + t.Run("bad host opens the config window", func(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") body := []byte(`{"host":"","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes"}`) if err := os.WriteFile(path, body, 0o600); err != nil { t.Fatal(err) } - _, err := station.New(station.Options{ConfigPath: path}) - if err == nil || err.Error() != "host is empty" { - t.Fatalf("New() error = %v, want host is empty", err) + st, err := station.New(station.Options{ConfigPath: path}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + got := st.State() + if !got.NeedsConfig || !got.ConfigOpen || got.Detail != "host is empty" { + t.Fatalf("State = %+v, want the Config window open with host is empty", got) } after, err := os.ReadFile(path) if err != nil { @@ -393,6 +431,115 @@ func assertKeyOrder(t *testing.T, body []byte) { } } +func TestInvalidSaveWritesNothingAndDoesNotConnect(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() { _ = st.Run(ctx) }() + + err = st.Save(station.ConfigForm{ + Host: "", + Port: port, + Callsign: "DL1ABC", + AuthKey: "key", + TalkGroup: 20, + CertificateDir: "./funknetz", + LogFile: "/tmp/funknetz.log", + }) + if err == nil || err.Error() != "host is empty" { + t.Fatalf("Save() error = %v, want host is empty", err) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("stat = %v, want the file absent", statErr) + } + cfg := st.Config() + if !cfg.Open || cfg.Error != "host is empty" || cfg.Form.Callsign != "DL1ABC" { + t.Fatalf("Config = %+v, want the window open with the host error and the edited callsign", cfg) + } + waitCtx, waitCancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer waitCancel() + if _, err := st.Wait(waitCtx, func(s station.State) bool { return s.Connection != "Disconnected" }); err == nil { + t.Fatalf("State = %+v, want it to stay Disconnected", st.State()) + } + 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 TestValidSaveCreatesFileAndConnects(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 + dir := t.TempDir() + path := filepath.Join(dir, "funknetz.json") + logFile := filepath.Join(dir, "funknetz.log") + 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() { _ = st.Run(ctx) }() + + err = st.Save(station.ConfigForm{ + Host: " 127.0.0.1 ", + Port: port, + Callsign: " dl1abc ", + AuthKey: "a b", + TalkGroup: 20, + CertificateDir: "./funknetz", + LogFile: logFile, + }) + if err != nil { + t.Fatal(err) + } + if st.Config().Open || st.State().NeedsConfig { + t.Fatalf("State = %+v, Config open = %v, want the Config window closed", st.State(), st.Config().Open) + } + if st.State().Callsign != "DL1ABC" || st.State().TalkGroup != 20 { + t.Fatalf("State = %+v, want callsign DL1ABC talk group 20", st.State()) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { + t.Fatalf("mode = %o, want %o", got, want) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := fmt.Sprintf("{\"host\":\"127.0.0.1\",\"port\":%d,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":%q}\n", port, logFile) + if string(body) != want { + t.Fatalf("file = %s, want %s", body, want) + } + assertKeyOrder(t, body) + ln.(*net.TCPListener).SetDeadline(time.Now().Add(2 * time.Second)) + conn, err := ln.Accept() + if err != nil { + t.Fatal(err) + } + conn.Close() +} + func TestMissingCertificateDirIsWrittenAfterTalkGroup(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") diff --git a/station/config_window.go b/station/config_window.go new file mode 100644 index 0000000..abdb41c --- /dev/null +++ b/station/config_window.go @@ -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) +} diff --git a/station/config_window_test.go b/station/config_window_test.go new file mode 100644 index 0000000..2fa996f --- /dev/null +++ b/station/config_window_test.go @@ -0,0 +1,401 @@ +package station_test + +import ( + "context" + "errors" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "gitea.neitzel.de/konrad/funknetz/station" +) + +func TestOpenConfigRereadsTheFile(t *testing.T) { + path := writeConfigFile(t, fullConfig("127.0.0.1", 5308, "DL1ABC", "key", 20, "/tmp/funknetz.log")) + st, err := station.New(station.Options{ConfigPath: path}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + edited := fullConfig("127.0.0.1", 5308, "DL1ABD", "key", 21, "/tmp/funknetz.log") + if err := os.WriteFile(path, []byte(edited), 0o600); err != nil { + t.Fatal(err) + } + st.OpenConfig() + cfg := st.Config() + if !cfg.Open || cfg.Error != "" || cfg.Form.Callsign != "DL1ABD" || cfg.Form.TalkGroup != 21 { + t.Fatalf("Config = %+v, want the hand-edited callsign and talk group", cfg) + } + if st.State().Callsign != "DL1ABC" || st.State().TalkGroup != 20 { + t.Fatalf("State = %+v, want the session to keep the loaded config", st.State()) + } +} + +func TestFailedRereadKeepsTheSession(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + if err := os.WriteFile(in.path, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + in.st.OpenConfig() + cfg := in.st.Config() + if !cfg.Open || !strings.Contains(cfg.Error, "the file could not be read") { + t.Fatalf("Config = %+v, want an open window and a read error", cfg) + } + got := in.st.State() + if got.Connection != "Connected" || got.Callsign != "DL1ABC" || got.TalkGroup != 20 { + t.Fatalf("State = %+v, want the live session kept", got) + } + if err := connStillOpen(in.refl.conn()); err != nil { + t.Fatal(err) + } +} + +func TestCancelAfterLoadLeavesFileAndSession(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + before, err := os.ReadFile(in.path) + if err != nil { + t.Fatal(err) + } + in.st.OpenConfig() + outside := []byte(fullConfig("127.0.0.1", in.refl.port, "DL1ABD", "other", 21, "/tmp/funknetz.log")) + if err := os.WriteFile(in.path, outside, 0o600); err != nil { + t.Fatal(err) + } + if in.st.Cancel() { + t.Fatal("Cancel() quit after a loaded config") + } + if in.st.State().ConfigOpen || in.st.State().Connection != "Connected" || in.st.State().Callsign != "DL1ABC" { + t.Fatalf("State = %+v, want the window closed and the session kept", in.st.State()) + } + got, err := os.ReadFile(in.path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(outside) { + t.Fatalf("file = %s, want the outside edit left in place", got) + } + if string(before) == string(got) { + t.Fatal("file did not change, so the test did not prove Cancel left it alone") + } + if err := connStillOpen(in.refl.conn()); err != nil { + t.Fatal(err) + } +} + +func TestSaveRewritesAndReconnects(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + if err := os.Chmod(in.path, 0o640); err != nil { + t.Fatal(err) + } + in.st.OpenConfig() + form := in.st.Config().Form + form.TalkGroup = 21 + if err := in.st.Save(form); err != nil { + t.Fatal(err) + } + if in.st.Config().Open { + t.Fatal("Config window stayed open") + } + info, err := os.Stat(in.path) + if err != nil { + t.Fatal(err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o640); got != want { + t.Fatalf("mode = %o, want %o", got, want) + } + body, err := os.ReadFile(in.path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"talkGroup":21`) || !strings.HasSuffix(string(body), "\n") { + t.Fatalf("file = %s, want talk group 21 and a trailing newline", body) + } + assertKeyOrder(t, body) + + in.refl.accept(t) + declineTo2(t, in.refl) + challenge := make([]byte, 20) + for i := range challenge { + challenge[i] = byte(i + 1) + } + in.refl.writeFrame(t, 10, appendBytes(nil, challenge)) + typ, _ := in.refl.readFrame(t) + if typ != 11 { + t.Fatalf("auth type %d, want 11", typ) + } + in.refl.writeFrame(t, 12, nil) + in.refl.writeFrame(t, 100, serverInfo(7, []string{"db0abc"}, []string{"OPUS"})) + typ, _ = in.refl.readFrame(t) + if typ != 111 { + t.Fatalf("type %d, want software identity", typ) + } + typ, tgBody := in.refl.readFrame(t) + if typ != 106 { + t.Fatalf("type %d, want select talk group", typ) + } + gotTG, _ := takeU32(t, tgBody) + if gotTG != 21 { + t.Fatalf("talk group %d, want 21", gotTG) + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + state, err := in.st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connected" && s.TalkGroup == 21 }) + if err != nil { + t.Fatal(err) + } + if state.Callsign != "DL1ABC" { + t.Fatalf("Callsign = %q, want DL1ABC", state.Callsign) + } +} + +func TestInvalidSaveLeavesTheSession(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + before, err := os.ReadFile(in.path) + if err != nil { + t.Fatal(err) + } + in.st.OpenConfig() + form := in.st.Config().Form + form.Host = "" + err = in.st.Save(form) + if err == nil || err.Error() != "host is empty" { + t.Fatalf("Save() error = %v, want host is empty", err) + } + got, err := os.ReadFile(in.path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(before) { + t.Fatalf("file = %s, want it unchanged", got) + } + state := in.st.State() + if !state.ConfigOpen || state.Connection != "Connected" || state.Callsign != "DL1ABC" || state.Detail != "host is empty" { + t.Fatalf("State = %+v, want the window open, the session up, and the host error", state) + } + if err := connStillOpen(in.refl.conn()); err != nil { + t.Fatal(err) + } +} + +func TestPushToTalkIsOffWhileConfigIsOpen(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + in.st.OpenConfig() + in.st.HoldButton() + if in.st.State().Transmitting || in.st.State().Connection != "Connected" { + t.Fatalf("State = %+v, want listening with push-to-talk off", in.st.State()) + } + if err := connStillOpen(in.refl.conn()); err != nil { + t.Fatal(err) + } +} + +func TestConfigStaysClosedWhilePushToTalkIsHeld(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + in.st.HoldButton() + if !in.st.State().Transmitting { + t.Fatal("Transmitting = false, want the button held") + } + in.st.OpenConfig() + state := in.st.State() + if state.ConfigOpen || state.Detail != "Release push-to-talk" || !state.Transmitting { + t.Fatalf("State = %+v, want no Config window and a push-to-talk notice", state) + } + in.st.ReleaseButton() + if in.st.State().Detail == "Release push-to-talk" || in.st.State().Transmitting { + t.Fatalf("State = %+v, want the notice cleared after release", in.st.State()) + } +} + +func TestLoggingOnlySaveKeepsTheSession(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + logPath := filepath.Join(t.TempDir(), "station.log") + in.st.OpenConfig() + form := in.st.Config().Form + form.EnableLogging = true + form.LogFile = logPath + if err := in.st.Save(form); err != nil { + t.Fatal(err) + } + if in.st.Config().Open { + t.Fatal("Config window stayed open") + } + state := in.st.State() + if state.Connection != "Connected" || state.TalkGroup != 20 || state.Callsign != "DL1ABC" { + t.Fatalf("State = %+v, want the session kept", state) + } + if _, err := os.Stat(logPath); err != nil { + t.Fatalf("log file: %v", err) + } + if err := connStillOpen(in.refl.conn()); err != nil { + t.Fatal(err) + } +} + +func TestLoggingSaveDoesNotClearAFatalStop(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 + dir := t.TempDir() + path := writeConfigFile(t, fullConfig("127.0.0.1", port, "DL1ABC", "key", 20, filepath.Join(dir, "funknetz.log"))) + st, err := station.New(station.Options{ConfigPath: path}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + st.Fail("could not open the audio device") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go func() { _ = st.Run(ctx) }() + + st.OpenConfig() + form := st.Config().Form + form.EnableLogging = true + form.LogFile = filepath.Join(dir, "station.log") + if err := st.Save(form); err != nil { + t.Fatal(err) + } + if st.State().Detail != "could not open the audio device" || st.State().Connection != "Disconnected" { + t.Fatalf("State = %+v, want the fatal stop kept", st.State()) + } + 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 TestUnchangedSaveDoesNotReconnect(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + in.st.OpenConfig() + form := in.st.Config().Form + if err := in.st.Save(form); err != nil { + t.Fatal(err) + } + if in.st.Config().Open || in.st.State().Connection != "Connected" { + t.Fatalf("State = %+v, Config open = %v, want the window closed and the session kept", in.st.State(), in.st.Config().Open) + } + if err := connStillOpen(in.refl.conn()); err != nil { + t.Fatal(err) + } +} + +func TestSaveWriteFailureKeepsTheSession(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + before, err := os.ReadFile(in.path) + if err != nil { + t.Fatal(err) + } + dir := filepath.Dir(in.path) + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + in.st.OpenConfig() + form := in.st.Config().Form + form.TalkGroup = 21 + err = in.st.Save(form) + if err == nil || !strings.Contains(err.Error(), "the file could not be written") { + t.Fatalf("Save() error = %v, want a write failure", err) + } + state := in.st.State() + if !state.ConfigOpen || state.Connection != "Connected" || state.TalkGroup != 20 || !strings.Contains(state.Detail, "the file could not be written") { + t.Fatalf("State = %+v, want the window open, the session kept, and the write error", state) + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(in.path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(before) { + t.Fatalf("file = %s, want it unchanged", got) + } + if err := connStillOpen(in.refl.conn()); err != nil { + t.Fatal(err) + } +} + +func TestEachConnectionFieldReconnects(t *testing.T) { + tests := []struct { + name string + edit func(*station.ConfigForm, int) + }{ + {name: "host", edit: func(f *station.ConfigForm, _ int) { f.Host = "localhost" }}, + {name: "callsign", edit: func(f *station.ConfigForm, _ int) { f.Callsign = "DL1ABD" }}, + {name: "authKey", edit: func(f *station.ConfigForm, _ int) { f.AuthKey = "other-key" }}, + {name: "certificateDir", edit: func(f *station.ConfigForm, _ int) { f.CertificateDir = "./radios" }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + in.st.OpenConfig() + form := in.st.Config().Form + tt.edit(&form, in.refl.port) + if err := in.st.Save(form); err != nil { + t.Fatal(err) + } + if in.st.Config().Open { + t.Fatal("Config window stayed open") + } + old := in.refl.conn() + in.refl.accept(t) + if err := connStillOpen(old); err == nil { + t.Fatal("old session stayed open") + } + }) + } + + t.Run("port", func(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) + in.st.OpenConfig() + form := in.st.Config().Form + form.Port = ln.Addr().(*net.TCPAddr).Port + if err := in.st.Save(form); err != nil { + t.Fatal(err) + } + old := in.refl.conn() + ln.(*net.TCPListener).SetDeadline(time.Now().Add(2 * time.Second)) + conn, err := ln.Accept() + if err != nil { + t.Fatal(err) + } + conn.Close() + if err := connStillOpen(old); err == nil { + t.Fatal("old session stayed open") + } + }) +} + +func fullConfig(host string, port int, callsign, authKey string, talkGroup int, logFile string) string { + return "{\"host\":\"" + host + "\",\"port\":" + strconv.Itoa(port) + ",\"callsign\":\"" + callsign + "\",\"authKey\":\"" + authKey + "\",\"talkGroup\":" + strconv.Itoa(talkGroup) + ",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"" + logFile + "\"}\n" +} + +func connStillOpen(conn net.Conn) error { + if err := conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)); err != nil { + return err + } + var hdr [4]byte + _, err := io.ReadFull(conn, hdr[:]) + if err == nil { + return nil + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return nil + } + return err +} diff --git a/station/session.go b/station/session.go index 8427511..84de51f 100644 --- a/station/session.go +++ b/station/session.go @@ -36,17 +36,37 @@ func (s *Station) Run(ctx context.Context) error { return nil } s.mu.Lock() - if s.fatal != "" { + if s.fatal != "" || !s.loaded { s.mu.Unlock() - <-ctx.Done() - return nil + select { + case <-ctx.Done(): + return nil + case <-s.wake: + } + continue } + s.kick = false s.state.Connection = "Connecting" s.publish() host, port := s.cfg.host, s.cfg.port + epoch := s.epoch + dialCtx, dialCancel := context.WithCancel(ctx) + s.cancelDial = dialCancel s.mu.Unlock() - conn, err := dialStation(ctx, host, port) + conn, err := dialStation(dialCtx, host, port) + dialCancel() + s.mu.Lock() + s.cancelDial = nil + stale := s.epoch != epoch + s.mu.Unlock() + if stale { + if conn != nil { + conn.Close() + } + s.drainApply() + continue + } if err != nil { if ctx.Err() != nil { return nil @@ -61,17 +81,28 @@ func (s *Station) Run(ctx context.Context) error { continue } - err = s.session(ctx, conn, major) + err = s.session(ctx, conn, major, epoch) conn.Close() fallback := errors.Is(err, errFallback) reconnect := errors.Is(err, errReconnect) s.mu.Lock() s.closeUDP() + if s.conn == conn { + s.conn = nil + } s.mic = nil s.play = nil s.audioHeard = false s.flushWaiting = false - if fallback || reconnect { + kicked := s.kick + if kicked { + s.kick = false + s.drainApply() + if s.up { + s.noteDownLocked(s.clock.Now(), false) + } + } + if fallback || reconnect || kicked { s.roomBusy = false s.remoteTalker = "" s.mu.Unlock() @@ -120,6 +151,8 @@ func (s *Station) wait(ctx context.Context, d time.Duration) bool { return false case <-timer.C(): return true + case <-s.apply: + return true } } @@ -263,7 +296,21 @@ const ( phChallenge ) -func (s *Station) session(ctx context.Context, conn net.Conn, major uint16) error { +func (s *Station) session(ctx context.Context, conn net.Conn, major uint16, epoch uint64) error { + s.mu.Lock() + s.conn = conn + stale := s.epoch != epoch + s.mu.Unlock() + if stale { + return errReconnect + } + defer func() { + s.mu.Lock() + if s.conn == conn { + s.conn = nil + } + s.mu.Unlock() + }() done := make(chan struct{}) var closeOnce sync.Once closeConn := func() { closeOnce.Do(func() { conn.Close() }) } @@ -721,7 +768,7 @@ func (s *Station) closeUDP() { } func (s *Station) transmitting() bool { - if s.fatal != "" || s.state.Connection != "Connected" || s.roomBusy { + if s.configOpen || s.fatal != "" || s.state.Connection != "Connected" || s.roomBusy { return false } return s.buttonHeld || s.spaceHeld @@ -731,6 +778,8 @@ func (s *Station) publish() { s.noteTransmitLocked() s.state.Callsign = s.cfg.callsign s.state.TalkGroup = s.cfg.talkGroup + s.state.NeedsConfig = !s.loaded + s.state.ConfigOpen = s.configOpen if s.fatal != "" { s.state.Connection = "Disconnected" s.state.Detail = s.fatal @@ -764,6 +813,11 @@ func (s *Station) publish() { } } } + if s.configOpen && s.configErr != "" { + s.state.Detail = s.configErr + } else if s.openBlocked != "" { + s.state.Detail = s.openBlocked + } select { case s.wake <- struct{}{}: default: diff --git a/station/station.go b/station/station.go index 920ea84..ab08431 100644 --- a/station/station.go +++ b/station/station.go @@ -27,12 +27,25 @@ type State struct { Protocol string `json:"protocol"` Talker string `json:"talker"` Transmitting bool `json:"transmitting"` + NeedsConfig bool `json:"needsConfig"` + ConfigOpen bool `json:"configOpen"` } // Station is the seam tests and the window use. type Station struct { - cfg loaded - clock Clock + cfg loaded + clock Clock + path string + loaded bool + configOpen bool + configErr string + openBlocked string + form ConfigForm + epoch uint64 + kick bool + conn net.Conn + cancelDial context.CancelFunc + apply chan struct{} mu sync.Mutex state State @@ -76,38 +89,55 @@ type Station struct { talkStart time.Time } -// New loads the config at opt.ConfigPath. A missing file is created mode 0600 -// and New returns an error so the caller can exit before opening a window. +// New loads the config at opt.ConfigPath. A missing or invalid file does not +// stop the station: the Config window opens and the file is left uncreated +// until Save. New returns an error when the path cannot be prepared, such as +// an unknown home directory. func New(opt Options) (*Station, error) { - cfg, err := loadConfig(opt.ConfigPath) - if err != nil { - return nil, err - } clock := opt.Clock if clock == nil { clock = wallClock{} } + out, err := loadConfig(opt.ConfigPath) + if err != nil { + return nil, err + } detail := "Idle" - if cfg.certFatal != "" { - detail = cfg.certFatal - } else if cfg.logNotice != "" { - detail = cfg.logNotice + if out.detail != "" { + detail = out.detail + } + call, tg := out.cfg.callsign, out.cfg.talkGroup + if !out.loaded && out.cfg.callsign == "" { + call = out.form.Callsign + tg = out.form.TalkGroup + } + errText := "" + if !out.loaded { + errText = out.detail } s := &Station{ - cfg: cfg, - clock: clock, - wake: make(chan struct{}, 1), - logPath: cfg.logPath, - logNotice: cfg.logNotice, - fatal: cfg.certFatal, + cfg: out.cfg, + clock: clock, + path: opt.ConfigPath, + loaded: out.loaded, + configOpen: !out.loaded, + configErr: errText, + form: out.form, + wake: make(chan struct{}, 1), + apply: make(chan struct{}, 1), + logPath: out.cfg.logPath, + logNotice: out.cfg.logNotice, + fatal: out.cfg.certFatal, state: State{ - Connection: "Disconnected", - Detail: detail, - Callsign: cfg.callsign, - TalkGroup: cfg.talkGroup, + Connection: "Disconnected", + Detail: detail, + Callsign: call, + TalkGroup: tg, + NeedsConfig: !out.loaded, + ConfigOpen: !out.loaded, }, } - if cfg.enableLogging { + if out.loaded && out.cfg.enableLogging { s.openLog() } return s, nil @@ -120,6 +150,18 @@ func (s *Station) State() State { return s.state } +// Config returns the Config window's form and whether it is open. +func (s *Station) Config() ConfigState { + s.mu.Lock() + defer s.mu.Unlock() + return ConfigState{ + Open: s.configOpen, + NeedsConfig: !s.loaded, + Error: s.configErr, + Form: s.form, + } +} + // Wait blocks until pred is true or ctx ends. func (s *Station) Wait(ctx context.Context, pred func(State) bool) (State, error) { s.mu.Lock() @@ -169,6 +211,9 @@ func (s *Station) afterHold(was bool) { if was && !s.transmitting() { s.endTransmit() } + if !s.buttonHeld && !s.spaceHeld { + s.openBlocked = "" + } s.publish() }