Let the operator edit the station config in the window instead of hand-editing the file and relaunching.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-11 19:19:05 +02:00
co-authored by Cursor
parent e642b24be0
commit 775d1b5c82
12 changed files with 1567 additions and 179 deletions
+31 -2
View File
@@ -5,17 +5,22 @@ import (
"fmt" "fmt"
"io" "io"
wailsruntime "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"
) )
// App is the window's view of the station. // App is the window's view of the station.
type App struct { type App struct {
ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
station *station.Station station *station.Station
quit func(context.Context)
} }
func (a *App) startup(ctx context.Context) { func (a *App) startup(ctx context.Context) {
a.ctx = ctx
runCtx, cancel := context.WithCancel(ctx) runCtx, cancel := context.WithCancel(ctx)
a.cancel = cancel a.cancel = cancel
if err := audio.Start(runCtx, a.station.WriteMicrophone, a.station.TakePlayback); err != nil { 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. // ReleaseSpace reports that Space was released.
func (a *App) ReleaseSpace() { a.station.ReleaseSpace() } 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) { func openStation(args []string) (*station.Station, error) {
path, err := resolveConfigPath(args) path, err := resolveConfigPath(args)
if err != nil { if err != nil {
@@ -54,8 +82,9 @@ func openStation(args []string) (*station.Station, error) {
return station.New(station.Options{ConfigPath: path}) return station.New(station.Options{ConfigPath: path})
} }
// openStationReported opens the station from args. A flag or config failure is // openStationReported opens the station from args. A flag or path failure is
// one line on stderr and the returned error. The window is not opened. // 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) { func openStationReported(args []string, stderr io.Writer) (*station.Station, error) {
st, err := openStation(args) st, err := openStation(args)
if err != nil { if err != nil {
+80 -53
View File
@@ -4,12 +4,11 @@ package main
import ( import (
"bytes" "bytes"
"context"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"encoding/json/v2"
) )
func TestResolveConfigPathDefaultIsHome(t *testing.T) { func TestResolveConfigPathDefaultIsHome(t *testing.T) {
@@ -86,20 +85,15 @@ func TestOpenStationUsesResolvedPath(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "custom.json") path := filepath.Join(dir, "custom.json")
_, err := openStation([]string{"-c", path}) st, 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)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("mode = %o, want %o", got, want) 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. // 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 { if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
st, err := openStation([]string{"--config", path}) st, err = openStation([]string{"--config", path})
if err != nil { if err != nil {
t.Fatal(err) 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") path := filepath.Join(t.TempDir(), "lab.json")
var stderr bytes.Buffer var stderr bytes.Buffer
st, err := openStationReported([]string{"-c", path}, &stderr) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { if st == nil {
t.Fatalf("mode = %o, want %o", got, want) t.Fatal("station = nil, want a station so the window can open")
} }
body, err := os.ReadFile(path) if stderr.Len() != 0 {
if err != nil { t.Fatalf("stderr = %q, want empty", stderr.String())
t.Fatal(err)
} }
var got map[string]any if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
if err := json.Unmarshal(body, &got); err != nil { t.Fatalf("stat = %v, want the file absent", statErr)
t.Fatalf("created file is not JSON: %v\n%s", err, body)
} }
if got["host"] != "" || got["callsign"] != "" || got["authKey"] != "" { if !st.State().NeedsConfig || !st.State().ConfigOpen {
t.Fatalf("string fields = %#v, want empty host, callsign, and authKey", got) t.Fatalf("State = %+v, want the Config window open", st.State())
} }
if got["port"] != float64(0) || got["talkGroup"] != float64(0) { form := st.Config().Form
t.Fatalf("numeric fields = %#v, want port and talkGroup at zero", got) 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)
if got["enableLogging"] != false {
t.Fatalf("enableLogging = %#v, want false", got["enableLogging"])
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got["logFile"] != filepath.Join(home, "funknetz.log") { if form.LogFile != filepath.Join(home, "funknetz.log") {
t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"]) 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) { func TestSelectedFileIsLeftByteForByte(t *testing.T) {
path := filepath.Join(t.TempDir(), "lab.json") 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 { if err := os.WriteFile(path, original, 0o640); err != nil {
t.Fatal(err) 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") 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") 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 { if err := os.WriteFile(path, body, 0o600); err != nil {
@@ -300,14 +277,64 @@ func TestEmptyHostOnSelectedFileNamesHost(t *testing.T) {
} }
var stderr bytes.Buffer var stderr bytes.Buffer
st, err := openStationReported([]string{"-c", path}, &stderr) st, err := openStationReported([]string{"-c", path}, &stderr)
if st != nil { if err != nil {
t.Fatal("station = non-nil, want nil") t.Fatal(err)
} }
if err == nil || !strings.Contains(err.Error(), "host") { if st == nil || !st.State().ConfigOpen || !strings.Contains(st.State().Detail, "host") {
t.Fatalf("error = %v, want it to name host", err) t.Fatalf("State = %+v, want the Config window open naming host", st.State())
} }
if stderr.String() != err.Error()+"\n" { if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want the error line", stderr.String()) 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)
} }
} }
+132 -3
View File
@@ -9,11 +9,31 @@ main {
padding: 24px; padding: 24px;
} }
#connection { .status {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 16px; margin: 0 0 16px;
}
#connection {
margin: 0;
font-size: 14px; 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 { #protocol {
min-height: 1.4em; min-height: 1.4em;
margin: 0; margin: 0;
@@ -24,7 +44,7 @@ main {
font-size: 20px; font-size: 20px;
} }
button { #ptt {
margin-top: 24px; margin-top: 24px;
width: 100%; width: 100%;
height: 64px; height: 64px;
@@ -36,6 +56,115 @@ button {
font-size: 18px; font-size: 18px;
} }
button.down { #ptt.down {
background: #9b2226; 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;
}
+162 -1
View File
@@ -1,4 +1,14 @@
const button = document.getElementById("ptt"); 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() { function api() {
if (!window.go || !window.go.main || !window.go.main.App) { if (!window.go || !window.go.main || !window.go.main.App) {
@@ -17,6 +27,104 @@ function line(state) {
return "Idle"; 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) { function render(state) {
document.getElementById("connection").textContent = state.connection; document.getElementById("connection").textContent = state.connection;
document.getElementById("callsign").textContent = state.callsign; document.getElementById("callsign").textContent = state.callsign;
@@ -25,6 +133,12 @@ function render(state) {
protocol.textContent = state.protocol ? "Protocol " + state.protocol : ""; protocol.textContent = state.protocol ? "Protocol " + state.protocol : "";
document.getElementById("detail").textContent = line(state); document.getElementById("detail").textContent = line(state);
button.classList.toggle("down", !!state.transmitting); 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() { function refresh() {
@@ -35,7 +149,47 @@ function refresh() {
app.State().then(render).catch(function () {}); 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) { button.addEventListener("pointerdown", function (event) {
if (button.disabled) {
return;
}
event.preventDefault(); event.preventDefault();
button.setPointerCapture(event.pointerId); button.setPointerCapture(event.pointerId);
const app = api(); const app = api();
@@ -57,11 +211,18 @@ button.addEventListener("pointercancel", releaseButton);
let spaceHeld = false; let spaceHeld = false;
window.addEventListener("keydown", function (event) { window.addEventListener("keydown", function (event) {
if (event.code === "Escape") {
if (!config.hidden) {
event.preventDefault();
cancelConfig();
}
return;
}
if (event.code !== "Space") { if (event.code !== "Space") {
return; return;
} }
event.preventDefault(); event.preventDefault();
if (event.repeat || spaceHeld) { if (!config.hidden || button.disabled || event.repeat || spaceHeld) {
return; return;
} }
spaceHeld = true; spaceHeld = true;
+27 -1
View File
@@ -7,13 +7,39 @@
</head> </head>
<body> <body>
<main> <main>
<p id="connection">Disconnected</p> <div class="status">
<p id="connection">Disconnected</p>
<button id="gear" type="button" aria-label="Config">⚙</button>
</div>
<p>Callsign <span id="callsign"></span></p> <p>Callsign <span id="callsign"></span></p>
<p>Talk group <span id="talkgroup"></span></p> <p>Talk group <span id="talkgroup"></span></p>
<p id="protocol"></p> <p id="protocol"></p>
<p id="detail">Idle</p> <p id="detail">Idle</p>
<button id="ptt" type="button">Push to talk</button> <button id="ptt" type="button">Push to talk</button>
</main> </main>
<div id="config" hidden>
<form id="config-form">
<h1>Config</h1>
<p id="config-error" hidden></p>
<label>Host <input id="host" name="host" autocomplete="off"></label>
<label>Port <input id="port" name="port" inputmode="numeric" autocomplete="off"></label>
<label>Callsign <input id="callsign-field" name="callsign" autocomplete="off"></label>
<label>Auth key
<span class="secret">
<input id="authKey" name="authKey" type="password" autocomplete="off">
<button id="auth-toggle" type="button">Show</button>
</span>
</label>
<label>Talk group <input id="talkGroup" name="talkGroup" inputmode="numeric" autocomplete="off"></label>
<label>Certificate directory <input id="certificateDir" name="certificateDir" autocomplete="off"></label>
<label class="check">Enable logging <input id="enableLogging" name="enableLogging" type="checkbox"></label>
<label>Log file <input id="logFile" name="logFile" autocomplete="off"></label>
<div class="actions">
<button id="cancel" type="button">Cancel</button>
<button id="save" type="submit">Save</button>
</div>
</form>
</div>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -21,6 +21,7 @@ type loggedIn struct {
refl *reflector refl *reflector
cancel context.CancelFunc cancel context.CancelFunc
id uint16 id uint16
path string
} }
func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []string) loggedIn { 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" { if got.Protocol != "2.0" {
t.Fatalf("Protocol = %q, want 2.0", got.Protocol) 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) { func declineTo2(t *testing.T, refl *reflector) {
+126 -40
View File
@@ -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})?$`) 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) body, err := os.ReadFile(path)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) { form, ferr := templateForm()
if err := writeTemplate(path); err != nil { if ferr != nil {
return loaded{}, err return configLoad{}, ferr
}
return loaded{}, fmt.Errorf("created %s", path)
} }
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) return parseConfig(path, body)
} }
func writeTemplate(path string) error { // ConfigForm is the eight fields the Config window edits.
logFile, err := defaultLogPath() type ConfigForm struct {
if err != nil { Host string `json:"host"`
return err Port int `json:"port"`
} Callsign string `json:"callsign"`
body, err := jsonv2.Marshal(templateFile{CertificateDir: "./funknetz", LogFile: logFile}) AuthKey string `json:"authKey"`
if err != nil { TalkGroup int `json:"talkGroup"`
return err CertificateDir string `json:"certificateDir"`
} EnableLogging bool `json:"enableLogging"`
body = append(body, '\n') LogFile string `json:"logFile"`
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)
} }
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 var shape fileShape
if err := jsonv2.Unmarshal(body, &shape); err != nil { 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 certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString
if block, notice := blockedLog(shape); block || certBad { if block, notice := blockedLog(shape); block || certBad {
cfg, err := parseOperational(shape) cfg, err := parseOperational(shape)
if err != nil { if err != nil {
return loaded{}, err return refused(shape, err)
} }
cfg.logNotice = notice cfg.logNotice = notice
if !block { if !block {
cfg = applyLog(cfg, shape) cfg = applyLog(cfg, shape)
} }
return applyCertificate(cfg, shape, path), nil return finish(shape, applyCertificate(cfg, shape, path))
} }
if missingCertificate(shape) || missingLog(shape) { if missingCertificate(shape) || missingLog(shape) {
if !operationalKindsOK(shape) { if !operationalKindsOK(shape) {
return parseOperational(shape) _, err := parseOperational(shape)
if err != nil {
return refused(shape, err)
}
} }
var err error var err error
shape, err = withLogDefaults(shape) shape, err = withLogDefaults(shape)
if err != nil { if err != nil {
return loaded{}, err return configLoad{}, err
} }
if err := rewriteConfig(path, shape); err != nil { 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) cfg, err := parseOperational(shape)
if err != nil { 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) { func parseOperational(shape fileShape) (loaded, error) {
@@ -304,13 +386,17 @@ func rewriteConfig(path string, shape fileShape) error {
return err return err
} }
body = append(body, '\n') 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") tmp, err := os.CreateTemp(filepath.Dir(path), ".funknetz-*.json")
if err != nil { if err != nil {
return err return err
} }
tmpName := tmp.Name() tmpName := tmp.Name()
_, werr := tmp.Write(body) _, werr := tmp.Write(body)
cerr := tmp.Chmod(info.Mode().Perm()) cerr := tmp.Chmod(mode)
clerr := tmp.Close() clerr := tmp.Close()
if werr != nil || cerr != nil || clerr != nil { if werr != nil || cerr != nil || clerr != nil {
os.Remove(tmpName) os.Remove(tmpName)
+194 -47
View File
@@ -15,53 +15,46 @@ import (
"gitea.neitzel.de/konrad/funknetz/station" "gitea.neitzel.de/konrad/funknetz/station"
) )
func TestMissingConfigIsCreatedAndRejected(t *testing.T) { func TestMissingConfigOpensWithoutCreating(t *testing.T) {
path := filepath.Join(t.TempDir(), ".funknetz.json") path := filepath.Join(t.TempDir(), ".funknetz.json")
_, err := station.New(station.Options{ConfigPath: path}) st, 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)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { t.Cleanup(st.Close)
t.Fatalf("mode = %o, want %o", got, want) if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("stat = %v, want the file absent", statErr)
} }
body, err := os.ReadFile(path) got := st.State()
if err != nil { if !got.NeedsConfig || !got.ConfigOpen || got.Connection != "Disconnected" {
t.Fatal(err) t.Fatalf("State = %+v, want Disconnected with the Config window open", got)
} }
var got map[string]any cfg := st.Config()
if err := jsonv2.Unmarshal(body, &got); err != nil { if !cfg.Open || !cfg.NeedsConfig || cfg.Error != "" {
t.Fatalf("created file is not JSON: %v\n%s", err, body) t.Fatalf("Config = %+v, want an open needs-config form and no error", cfg)
}
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)
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got["enableLogging"] != false { form := cfg.Form
t.Fatalf("enableLogging = %#v, want false", got["enableLogging"]) 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") { if form.Port != 0 || form.TalkGroup != 0 {
t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"]) t.Fatalf("numeric fields = %+v, want port and talkGroup at zero", form)
} }
if got["certificateDir"] != "./funknetz" { if form.EnableLogging {
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"]) 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) { func TestExistingConfigIsNotRewritten(t *testing.T) {
@@ -133,12 +126,17 @@ func TestInvalidConfigNamesTheField(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err = station.New(station.Options{ConfigPath: path}) st, err := station.New(station.Options{ConfigPath: path})
if err == nil { if err != nil {
t.Fatal("New() error = nil, want a field rejection") t.Fatal(err)
} }
if err.Error() != tt.want { t.Cleanup(st.Close)
t.Fatalf("New() error = %q, want %q", err.Error(), tt.want) 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) after, err := os.ReadFile(path)
if err != nil { 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) { func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) {
path := filepath.Join(t.TempDir(), "funknetz.json") path := filepath.Join(t.TempDir(), "funknetz.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err := station.New(station.Options{ConfigPath: path}) st, err := station.New(station.Options{ConfigPath: path})
if err == nil { if err != nil {
t.Fatal("New() error = nil, want a read failure") t.Fatal(err)
} }
if !strings.Contains(err.Error(), "the file could not be read") { t.Cleanup(st.Close)
t.Fatalf("New() error = %q, want it to say the file could not be read", err.Error()) got := st.Config()
if !got.Open || !got.NeedsConfig {
t.Fatalf("Config = %+v, want the Config window open", got)
} }
if strings.Contains(err.Error(), "callsign") { if !strings.Contains(got.Error, "the file could not be read") {
t.Fatalf("New() error = %q, want no field name", err.Error()) 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") path := filepath.Join(t.TempDir(), "funknetz.json")
body := []byte(`{"host":"","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes"}`) body := []byte(`{"host":"","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes"}`)
if err := os.WriteFile(path, body, 0o600); err != nil { if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err := station.New(station.Options{ConfigPath: path}) st, err := station.New(station.Options{ConfigPath: path})
if err == nil || err.Error() != "host is empty" { if err != nil {
t.Fatalf("New() error = %v, want host is empty", err) 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) after, err := os.ReadFile(path)
if err != nil { 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) { func TestMissingCertificateDirIsWrittenAfterTalkGroup(t *testing.T) {
path := filepath.Join(t.TempDir(), "funknetz.json") 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") original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
+282
View File
@@ -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)
}
+401
View File
@@ -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
}
+62 -8
View File
@@ -36,17 +36,37 @@ func (s *Station) Run(ctx context.Context) error {
return nil return nil
} }
s.mu.Lock() s.mu.Lock()
if s.fatal != "" { if s.fatal != "" || !s.loaded {
s.mu.Unlock() s.mu.Unlock()
<-ctx.Done() select {
return nil case <-ctx.Done():
return nil
case <-s.wake:
}
continue
} }
s.kick = false
s.state.Connection = "Connecting" s.state.Connection = "Connecting"
s.publish() s.publish()
host, port := s.cfg.host, s.cfg.port host, port := s.cfg.host, s.cfg.port
epoch := s.epoch
dialCtx, dialCancel := context.WithCancel(ctx)
s.cancelDial = dialCancel
s.mu.Unlock() 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 err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
return nil return nil
@@ -61,17 +81,28 @@ func (s *Station) Run(ctx context.Context) error {
continue continue
} }
err = s.session(ctx, conn, major) err = s.session(ctx, conn, major, epoch)
conn.Close() conn.Close()
fallback := errors.Is(err, errFallback) fallback := errors.Is(err, errFallback)
reconnect := errors.Is(err, errReconnect) reconnect := errors.Is(err, errReconnect)
s.mu.Lock() s.mu.Lock()
s.closeUDP() s.closeUDP()
if s.conn == conn {
s.conn = nil
}
s.mic = nil s.mic = nil
s.play = nil s.play = nil
s.audioHeard = false s.audioHeard = false
s.flushWaiting = 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.roomBusy = false
s.remoteTalker = "" s.remoteTalker = ""
s.mu.Unlock() s.mu.Unlock()
@@ -120,6 +151,8 @@ func (s *Station) wait(ctx context.Context, d time.Duration) bool {
return false return false
case <-timer.C(): case <-timer.C():
return true return true
case <-s.apply:
return true
} }
} }
@@ -263,7 +296,21 @@ const (
phChallenge 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{}) done := make(chan struct{})
var closeOnce sync.Once var closeOnce sync.Once
closeConn := func() { closeOnce.Do(func() { conn.Close() }) } closeConn := func() { closeOnce.Do(func() { conn.Close() }) }
@@ -721,7 +768,7 @@ func (s *Station) closeUDP() {
} }
func (s *Station) transmitting() bool { 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 false
} }
return s.buttonHeld || s.spaceHeld return s.buttonHeld || s.spaceHeld
@@ -731,6 +778,8 @@ func (s *Station) publish() {
s.noteTransmitLocked() s.noteTransmitLocked()
s.state.Callsign = s.cfg.callsign s.state.Callsign = s.cfg.callsign
s.state.TalkGroup = s.cfg.talkGroup s.state.TalkGroup = s.cfg.talkGroup
s.state.NeedsConfig = !s.loaded
s.state.ConfigOpen = s.configOpen
if s.fatal != "" { if s.fatal != "" {
s.state.Connection = "Disconnected" s.state.Connection = "Disconnected"
s.state.Detail = s.fatal 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 { select {
case s.wake <- struct{}{}: case s.wake <- struct{}{}:
default: default:
+68 -23
View File
@@ -27,12 +27,25 @@ type State struct {
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Talker string `json:"talker"` Talker string `json:"talker"`
Transmitting bool `json:"transmitting"` Transmitting bool `json:"transmitting"`
NeedsConfig bool `json:"needsConfig"`
ConfigOpen bool `json:"configOpen"`
} }
// Station is the seam tests and the window use. // Station is the seam tests and the window use.
type Station struct { type Station struct {
cfg loaded cfg loaded
clock Clock 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 mu sync.Mutex
state State state State
@@ -76,38 +89,55 @@ type Station struct {
talkStart time.Time talkStart time.Time
} }
// New loads the config at opt.ConfigPath. A missing file is created mode 0600 // New loads the config at opt.ConfigPath. A missing or invalid file does not
// and New returns an error so the caller can exit before opening a window. // 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) { func New(opt Options) (*Station, error) {
cfg, err := loadConfig(opt.ConfigPath)
if err != nil {
return nil, err
}
clock := opt.Clock clock := opt.Clock
if clock == nil { if clock == nil {
clock = wallClock{} clock = wallClock{}
} }
out, err := loadConfig(opt.ConfigPath)
if err != nil {
return nil, err
}
detail := "Idle" detail := "Idle"
if cfg.certFatal != "" { if out.detail != "" {
detail = cfg.certFatal detail = out.detail
} else if cfg.logNotice != "" { }
detail = cfg.logNotice 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{ s := &Station{
cfg: cfg, cfg: out.cfg,
clock: clock, clock: clock,
wake: make(chan struct{}, 1), path: opt.ConfigPath,
logPath: cfg.logPath, loaded: out.loaded,
logNotice: cfg.logNotice, configOpen: !out.loaded,
fatal: cfg.certFatal, 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{ state: State{
Connection: "Disconnected", Connection: "Disconnected",
Detail: detail, Detail: detail,
Callsign: cfg.callsign, Callsign: call,
TalkGroup: cfg.talkGroup, TalkGroup: tg,
NeedsConfig: !out.loaded,
ConfigOpen: !out.loaded,
}, },
} }
if cfg.enableLogging { if out.loaded && out.cfg.enableLogging {
s.openLog() s.openLog()
} }
return s, nil return s, nil
@@ -120,6 +150,18 @@ func (s *Station) State() State {
return s.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. // Wait blocks until pred is true or ctx ends.
func (s *Station) Wait(ctx context.Context, pred func(State) bool) (State, error) { func (s *Station) Wait(ctx context.Context, pred func(State) bool) (State, error) {
s.mu.Lock() s.mu.Lock()
@@ -169,6 +211,9 @@ func (s *Station) afterHold(was bool) {
if was && !s.transmitting() { if was && !s.transmitting() {
s.endTransmit() s.endTransmit()
} }
if !s.buttonHeld && !s.spaceHeld {
s.openBlocked = ""
}
s.publish() s.publish()
} }