Disconnected
+Disconnected
+ +Callsign
Talk group
Idle
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