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:
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+132
-3
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+162
-1
@@ -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;
|
||||||
|
|||||||
Vendored
+26
@@ -7,13 +7,39 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
<div class="status">
|
||||||
<p id="connection">Disconnected</p>
|
<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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+125
-39
@@ -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 {
|
||||||
|
form, ferr := templateForm()
|
||||||
|
if ferr != nil {
|
||||||
|
return configLoad{}, ferr
|
||||||
|
}
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
if err := writeTemplate(path); err != nil {
|
return configLoad{form: form}, nil
|
||||||
return loaded{}, err
|
|
||||||
}
|
}
|
||||||
return loaded{}, fmt.Errorf("created %s", path)
|
return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil
|
||||||
}
|
|
||||||
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
|
|
||||||
}
|
}
|
||||||
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
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+61
-7
@@ -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 {
|
||||||
|
case <-ctx.Done():
|
||||||
return nil
|
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:
|
||||||
|
|||||||
+62
-17
@@ -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,
|
||||||
|
path: opt.ConfigPath,
|
||||||
|
loaded: out.loaded,
|
||||||
|
configOpen: !out.loaded,
|
||||||
|
configErr: errText,
|
||||||
|
form: out.form,
|
||||||
wake: make(chan struct{}, 1),
|
wake: make(chan struct{}, 1),
|
||||||
logPath: cfg.logPath,
|
apply: make(chan struct{}, 1),
|
||||||
logNotice: cfg.logNotice,
|
logPath: out.cfg.logPath,
|
||||||
fatal: cfg.certFatal,
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user