diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 10cf7ae..a3bfa89 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1175,6 +1175,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w r.Use(server.RecordMiddleware) r.Get("/", server.HandleRoot) + r.Get("/admin", server.HandleAdmin) r.Get("/health", server.HandleHealth) r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { // The favicon lives in the embedded web/img bundle, not under diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index af7f022..fea1c4d 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -34,6 +34,7 @@ GET /accounts/{account}/devices/{device}/presets handlers.( GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm +GET /admin handlers.(*Server).HandleAdmin-fm GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index a833cf9..c7b0103 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -2627,6 +2627,15 @@ type Settings struct { // is passed through verbatim; AfterTouch does not validate the // individual format tokens. TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"` + + // DefaultLanding selects what the root path "/" serves to a browser: + // "chooser" (or empty) — the neutral landing page that links to the + // player and the admin/setup console; + // "app" — redirect straight to the player UI (/app); + // "admin" — redirect straight to the admin console (/admin). + // API/speaker clients (non-HTML Accept) always get the version JSON + // regardless of this setting. + DefaultLanding string `json:"default_landing,omitempty"` } // GetSettings retrieves the global service settings. diff --git a/pkg/service/handlers/handlers_landing_test.go b/pkg/service/handlers/handlers_landing_test.go new file mode 100644 index 0000000..0122033 --- /dev/null +++ b/pkg/service/handlers/handlers_landing_test.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +func newLandingServer(t *testing.T, landing string) *Server { + t.Helper() + + ds := datastore.NewDataStore(t.TempDir()) + if landing != "" { + if err := ds.SaveSettings(datastore.Settings{ + ServerURL: "http://127.0.0.1:8000", + DefaultLanding: landing, + }); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + } + + return NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false) +} + +func htmlGet(path string) *http.Request { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Accept", "text/html") + + return req +} + +// TestHandleRootChooser: with no configured default, a browser at "/" +// gets the neutral chooser linking to both surfaces. +func TestHandleRootChooser(t *testing.T) { + server := newLandingServer(t, "") + + rec := httptest.NewRecorder() + server.HandleRoot(rec, htmlGet("/")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{`href="/app"`, `href="/admin"`, "Player"} { + if !strings.Contains(body, want) { + t.Errorf("chooser body missing %q", want) + } + } + + // The chooser is not the admin console. + if strings.Contains(body, "tab-settings") { + t.Error("chooser unexpectedly served the admin console") + } +} + +// TestHandleRootRedirects: default_landing app/admin redirects "/" to the +// matching surface for browsers. +func TestHandleRootRedirects(t *testing.T) { + for landing, want := range map[string]string{"app": "/app", "admin": "/admin"} { + server := newLandingServer(t, landing) + + rec := httptest.NewRecorder() + server.HandleRoot(rec, htmlGet("/")) + + if rec.Code != http.StatusFound { + t.Errorf("landing=%q: status = %d; want 302", landing, rec.Code) + } + + if got := rec.Header().Get("Location"); got != want { + t.Errorf("landing=%q: Location = %q; want %q", landing, got, want) + } + } +} + +// TestHandleRootJSONIgnoresLanding: API/speaker clients (non-HTML Accept) +// always get the version JSON, never a landing redirect, regardless of +// the configured default. +func TestHandleRootJSONIgnoresLanding(t *testing.T) { + server := newLandingServer(t, "app") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept", "application/json") + + rec := httptest.NewRecorder() + server.HandleRoot(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 (not a redirect)", rec.Code) + } + + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type = %q; want application/json", ct) + } +} + +// TestHandleAdminServesConsole: /admin serves the admin console itself. +func TestHandleAdminServesConsole(t *testing.T) { + server := newLandingServer(t, "") + + rec := httptest.NewRecorder() + server.HandleAdmin(rec, htmlGet("/admin")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + + if !strings.Contains(rec.Body.String(), "tab-settings") { + t.Error("admin body did not contain the console markup") + } +} diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go index 834925e..f8e81b9 100644 --- a/pkg/service/handlers/handlers_media.go +++ b/pkg/service/handlers/handlers_media.go @@ -13,6 +13,9 @@ import ( //go:embed web/index.html var indexHTML []byte +//go:embed web/landing.html +var landingHTML []byte + //go:embed web/css/* web/js/* web/img/favicon-braille* web/img/favicon* var webFS embed.FS @@ -113,10 +116,43 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { return } + // HTML branch: a browser hitting "/". Honour the configured default + // landing surface, otherwise serve the neutral chooser. The admin + // console itself now lives at /admin (served by HandleAdmin). + switch s.defaultLanding() { + case "app": + http.Redirect(w, r, "/app", http.StatusFound) + return + case "admin": + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write(landingHTML) +} + +// HandleAdmin serves the admin / setup console. It used to live at "/"; +// the chooser landing page took that spot, so the console moved here. +// Its assets (/web/*) and APIs (/setup, /mgmt) are absolute, so the page +// works unchanged at the new path. +func (s *Server) HandleAdmin(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html") _, _ = w.Write(indexHTMLVersioned) } +// defaultLanding returns the configured root-path behaviour for browsers +// ("chooser", "app", or "admin"), defaulting to "chooser" when unset or +// unreadable. +func (s *Server) defaultLanding() string { + persisted, err := s.ds.GetSettings() + if err != nil || persisted.DefaultLanding == "" { + return "chooser" + } + + return persisted.DefaultLanding +} + // HandleWeb returns a handler for serving web resources. func (s *Server) HandleWeb() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index f36f400..19ff7ac 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -189,6 +189,8 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { dnsRunning, actualBind := s.GetDNSRunning() + defaultLanding := s.defaultLanding() + var serverURLResolvedIP, serverURLResolveError string if ip, err := s.resolveServerURLIP(serverURL); err == nil { @@ -266,6 +268,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { "tts_language": ttsLanguage, "tts_voice": ttsVoice, "tts_volume": ttsVolume, + "default_landing": defaultLanding, }); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return @@ -296,12 +299,22 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { TTSVoice string `json:"tts_voice"` TTSVolume int `json:"tts_volume"` TLSExtraHosts *[]string `json:"tls_extra_hosts"` + DefaultLanding string `json:"default_landing"` } if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + // Normalise + validate the landing choice. Empty means "chooser". + defaultLanding := strings.ToLower(strings.TrimSpace(settings.DefaultLanding)) + switch defaultLanding { + case "", "chooser", "app", "admin": + default: + http.Error(w, "Invalid default_landing: must be chooser, app, or admin", http.StatusBadRequest) + return + } + if settings.DNSEnabled && settings.DNSUpstream == "" { // No strict requirement for DNSUpstream here as SetDNSSettings will // try to fall back to system DNS. We only log it if both are empty later. @@ -420,6 +433,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { TTSVoice: s.ttsVoice, TTSVolume: s.ttsVolume, TLSExtraHosts: resolvedTLSExtraHosts, + DefaultLanding: defaultLanding, }) dnsEnabled := s.dnsEnabled diff --git a/pkg/service/handlers/web/css/style.css b/pkg/service/handlers/web/css/style.css index 5078230..f176376 100644 --- a/pkg/service/handlers/web/css/style.css +++ b/pkg/service/handlers/web/css/style.css @@ -1,4 +1,33 @@ body { font-family: sans-serif; margin: 20px; } + +/* Shared accent bar (matches the app navbar and the landing page). It + breaks out of the body's 20px margin to sit full-bleed at the top. */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + height: 52px; + padding: 0 1.25rem; + margin: -20px -20px 16px; + background: #1a1a1a; + color: #fff; +} +.topbar .brand { display: flex; align-items: center; gap: .5rem; text-decoration: none; color: inherit; } +.topbar .brand img { width: 24px; height: 24px; filter: brightness(0) invert(1); } +.topbar .brand-text { display: flex; flex-direction: column; line-height: 1.1; } +.topbar .brand-name { font-size: 1.1rem; font-weight: 600; letter-spacing: .02em; } +.topbar .brand-subtitle { font-size: .7rem; font-weight: 400; opacity: .7; } +.topbar .bar-links a { + color: #fff; + font-size: .85rem; + opacity: .85; + text-decoration: none; + padding: .35rem .5rem; + border-radius: 4px; +} +.topbar .bar-links a:hover { opacity: 1; background: rgba(255, 255, 255, .12); } +@media (max-width: 480px) { .topbar .brand-subtitle { display: none; } } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } th { background-color: #f2f2f2; } diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index d69abf5..0c1558f 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -8,18 +8,20 @@ -

AfterTouch

-

- Bose SoundTouch Toolkit -

-

- 🎵 Open the Player (Web UI) → - - Control playback, volume, presets, zones, and browse TuneIn / RadioBrowser. - -

-

- This page is the admin / setup console (migration, settings, diagnostics). +

+ + + + AfterTouch + Bose SoundTouch Toolkit + + + +
+

+ Admin & Setup console: migration, settings, accounts, and diagnostics.

@@ -174,6 +176,18 @@
+
+ + +
+ What a browser sees at the root URL. The Player and Admin pages + stay reachable at /app and /admin either way. +
+
TLS extra hosts: diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index cefb45c..bd15bf5 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -322,6 +322,9 @@ async function fetchSettings() { if (settings.discovery_enabled !== undefined) { document.getElementById("discovery-enabled").checked = settings.discovery_enabled; } + if (settings.default_landing) { + document.getElementById("default-landing").value = settings.default_landing; + } if (settings.dns_enabled !== undefined) { document.getElementById("dns-enabled").checked = settings.dns_enabled; } @@ -460,6 +463,7 @@ async function updateLoggingSettings() { async function updateSettings() { const settings = { server_url: document.getElementById("target-domain").value, + default_landing: document.getElementById("default-landing").value, discovery_interval: document.getElementById("discovery-interval").value, discovery_enabled: document.getElementById("discovery-enabled").checked, dns_enabled: document.getElementById("dns-enabled").checked, diff --git a/pkg/service/handlers/web/landing.html b/pkg/service/handlers/web/landing.html new file mode 100644 index 0000000..dd19366 --- /dev/null +++ b/pkg/service/handlers/web/landing.html @@ -0,0 +1,237 @@ + + + + + + AfterTouch + + + + + +
+ + + + AfterTouch + Bose SoundTouch Toolkit + + + +
+ +
+

+ Your SoundTouch speakers, kept playing on your own network + after the Bose cloud shutdown. Where to? +

+ + +
+ + + + + + diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index 6474fb5..baec39b 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -170,7 +170,7 @@ function App() { return html`