mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
@@ -5,6 +5,24 @@
|
||||
SOUNDTOUCH_HOSTNAME=soundtouch.local
|
||||
SOUNDTOUCH_VERSION=latest
|
||||
|
||||
# Stockholm frontend (used by make prepare-stockholm and by the Go service at startup)
|
||||
# BACKEND_URL is the base URL your speakers and browser can reach the service at.
|
||||
# Corresponds to SERVER_URL in the Go service.
|
||||
# BACKEND_URL=http://soundtouch.local:8000
|
||||
#
|
||||
# STREAMING_URL is used for streaming.bose.com rewrites (defaults to BACKEND_URL).
|
||||
# Set to $(BACKEND_URL)/marge only when routing through a soundcork backend.
|
||||
# STREAMING_URL=http://soundtouch.local:8000
|
||||
#
|
||||
# AUTH_SERVICE_URL is written into config.json as the auth endpoint (defaults to BACKEND_URL).
|
||||
# A trailing slash is added automatically; the JS appends paths like "oauth/account/..." directly.
|
||||
# AUTH_SERVICE_URL=http://soundtouch.local:8000
|
||||
#
|
||||
# STOCKHOLM_BASE_PATH mounts the Stockholm UI under a URL prefix, freeing / for the management UI.
|
||||
# The bridge API (/api/native/*, /api/http-proxy) remains at root regardless of this setting.
|
||||
# Defaults to /stockholm. Set to empty to serve at root.
|
||||
# STOCKHOLM_BASE_PATH=/stockholm
|
||||
|
||||
# Discovery Settings
|
||||
DISCOVERY_TIMEOUT=5s
|
||||
UPNP_ENABLED=true
|
||||
|
||||
@@ -98,3 +98,11 @@ pids
|
||||
|
||||
# dotenv environment variables file (but keep .env.example)
|
||||
!.env.example
|
||||
|
||||
# Stockholm frontend — generated by `make prepare-stockholm`, not committed
|
||||
stockholm/
|
||||
|
||||
!pkg/service/stockholm/
|
||||
|
||||
# Stockholm source zip — large binary, place manually at stockholm_zip/stockholm.zip
|
||||
stockholm_zip/*.zip
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
Thank you for your interest in contributing to the Bose SoundTouch API Client! This project aims to provide a comprehensive, reliable, and well-tested Go library for controlling Bose SoundTouch devices.
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
All contributions are welcome — large or small:
|
||||
|
||||
- **Code suggestions** — bug fixes, new features, refactoring, performance improvements.
|
||||
- **Documentation updates** — README, guides, examples, troubleshooting notes, inline doc comments.
|
||||
- **Bug fixes** — even just a clear reproducer in an issue is a real contribution.
|
||||
- **Donations** — if the project kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation; everything in this repo stays MIT regardless.
|
||||
|
||||
By submitting a code or documentation contribution you agree to license it under MIT. The detailed guides below cover the mechanics.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
@@ -15,6 +26,7 @@ Thank you for your interest in contributing to the Bose SoundTouch API Client! T
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Device Testing](#device-testing)
|
||||
- [Community](#community)
|
||||
- [Support the Project](#support-the-project)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
@@ -465,6 +477,14 @@ Contributors will be:
|
||||
- **Mentioned in release notes** for significant contributions
|
||||
- **Credited in documentation** where appropriate
|
||||
|
||||
## Support the Project
|
||||
|
||||
If you want to support the maintenance effort beyond code:
|
||||
|
||||
[](https://github.com/sponsors/gesellix)
|
||||
|
||||
Sponsorship is entirely optional. Code, docs, and bug reports remain the most useful contributions for the project itself.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Dockerfile.stockholm — builds the Stockholm frontend preparation image.
|
||||
#
|
||||
# This image clones krahl/soundcork-stockholm-app, installs the required tools
|
||||
# (prettier, patch, unzip, jq), and is used exclusively to run the entrypoint
|
||||
# preparation step that extracts and patches the Stockholm frontend.
|
||||
#
|
||||
# Java is NOT included — we stop before `exec java`.
|
||||
#
|
||||
# Usage (see Makefile targets build-stockholm-image / prepare-stockholm):
|
||||
#
|
||||
# docker build --build-arg STOCKHOLM_APP_REF=main \
|
||||
# -f Dockerfile.stockholm -t soundcork-stockholm-app .
|
||||
#
|
||||
# docker run --rm \
|
||||
# -v "$PWD/stockholm_zip:/app/stockholm_zip:ro" \
|
||||
# -v "$PWD/stockholm:/app/stockholm" \
|
||||
# --entrypoint bash soundcork-stockholm-app \
|
||||
# -c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash'
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG STOCKHOLM_APP_REF=main
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
jq \
|
||||
unzip \
|
||||
nodejs \
|
||||
npm \
|
||||
patch && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g prettier@3.8.3 && npm cache clean --force
|
||||
|
||||
RUN git clone --depth 1 --branch "${STOCKHOLM_APP_REF}" \
|
||||
https://github.com/krahl/soundcork-stockholm-app /app
|
||||
|
||||
WORKDIR /app
|
||||
@@ -1,4 +1,7 @@
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm
|
||||
|
||||
# Load .env if present (simple KEY=VALUE format, no shell quoting)
|
||||
-include .env
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
@@ -31,6 +34,22 @@ BUILD_DIR=./build
|
||||
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
|
||||
BUILDFLAGS=-trimpath -ldflags="-s -w"
|
||||
|
||||
# Stockholm frontend preparation (see Dockerfile.stockholm and docs/stockholm-port-guide.md)
|
||||
# STOCKHOLM_APP_REF can be overridden to pin a specific commit: make build-stockholm-image STOCKHOLM_APP_REF=<sha>
|
||||
STOCKHOLM_IMAGE ?= soundcork-stockholm-app
|
||||
STOCKHOLM_APP_REF ?= main
|
||||
STOCKHOLM_ZIP_DIR ?= $(CURDIR)/stockholm_zip
|
||||
STOCKHOLM_DIR ?= $(CURDIR)/stockholm
|
||||
# URLs baked into stockholm/json/config.json during prepare-stockholm.
|
||||
# The Go service rewrites these again at startup using SERVER_URL / MARGE_URL,
|
||||
# so these only matter for static-file-only deployments or when pre-baking is desired.
|
||||
# Default to localhost:8000 (matches the Go service default).
|
||||
BACKEND_URL ?= http://localhost:8000
|
||||
# STREAMING_URL defaults to BACKEND_URL (no /marge suffix — set to $(BACKEND_URL)/marge for soundcork).
|
||||
STREAMING_URL ?= $(BACKEND_URL)
|
||||
# AUTH_SERVICE_URL defaults to BACKEND_URL; override to point at a different auth endpoint.
|
||||
AUTH_SERVICE_URL ?= $(BACKEND_URL)
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
|
||||
@@ -214,6 +233,18 @@ dev-service-proxy: build-service
|
||||
fi
|
||||
PYTHON_BACKEND_URL=$(PROXY_URL) $(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
# Run the service with the Stockholm frontend enabled. Requires that
|
||||
# `make prepare-stockholm` has been run at least once (the check below
|
||||
# avoids re-running the Docker container on every dev launch).
|
||||
dev-service-stockholm: build-service
|
||||
@if [ ! -f "$(STOCKHOLM_DIR)/index.html" ]; then \
|
||||
echo "Error: Stockholm not prepared at $(STOCKHOLM_DIR)."; \
|
||||
echo "Run 'make prepare-stockholm' first (needs stockholm_zip/stockholm.zip)."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Starting development service with Stockholm enabled from $(STOCKHOLM_DIR)..."
|
||||
STOCKHOLM_DIR=$(STOCKHOLM_DIR) $(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
dev-discover: build-cli
|
||||
@echo "Running device discovery..."
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -discover
|
||||
@@ -329,6 +360,69 @@ docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker build --target soundtouch-service -t soundtouch-service .
|
||||
|
||||
# Stockholm frontend preparation.
|
||||
# Requires: Docker, internet access (clones github.com/krahl/soundcork-stockholm-app).
|
||||
# No pre-built image is published; the image must be built locally before running prepare-stockholm.
|
||||
build-stockholm-image:
|
||||
@echo "Building Stockholm preparation image (clones upstream, installs prettier/patch)..."
|
||||
docker build \
|
||||
--build-arg STOCKHOLM_APP_REF=$(STOCKHOLM_APP_REF) \
|
||||
-f Dockerfile.stockholm \
|
||||
-t $(STOCKHOLM_IMAGE) \
|
||||
.
|
||||
|
||||
# Extracts and patches the Stockholm frontend using the upstream container image.
|
||||
# Requires: build-stockholm-image to have been run, and stockholm_zip/stockholm.zip to be present.
|
||||
# The resulting stockholm/ directory is used by the soundtouch-service at runtime.
|
||||
prepare-stockholm:
|
||||
@mkdir -p "$(STOCKHOLM_DIR)"
|
||||
@[ -f "$(STOCKHOLM_ZIP_DIR)/stockholm.zip" ] || { \
|
||||
echo "Error: $(STOCKHOLM_ZIP_DIR)/stockholm.zip not found."; \
|
||||
echo "Download the Stockholm zip and place it at stockholm_zip/stockholm.zip first."; \
|
||||
exit 1; }
|
||||
docker run --rm \
|
||||
-e BACKEND_URL=$(BACKEND_URL) \
|
||||
-e STREAMING_URL=$(STREAMING_URL) \
|
||||
-e AUTH_SERVICE_URL=$(AUTH_SERVICE_URL) \
|
||||
-v "$(STOCKHOLM_ZIP_DIR):/app/stockholm_zip:ro" \
|
||||
-v "$(STOCKHOLM_DIR):/app/stockholm" \
|
||||
--entrypoint bash \
|
||||
$(STOCKHOLM_IMAGE) \
|
||||
-c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash'
|
||||
@# Patch update-urls.sh: replace the hardcoded ${BACKEND_URL}/marge with
|
||||
@# ${STREAMING_URL:-${BACKEND_URL}} so the streaming URL is configurable and
|
||||
@# defaults to BACKEND_URL (no /marge suffix) rather than the soundcork convention.
|
||||
@script="$(STOCKHOLM_DIR)/json/update-urls.sh"; \
|
||||
awk '{ gsub(/\$$\{BACKEND_URL\}\/marge/, "$${STREAMING_URL:-$${BACKEND_URL}}"); print }' \
|
||||
"$$script" > "$$script.tmp" && mv "$$script.tmp" "$$script"
|
||||
@# Restore config.json from the backup that update-urls.sh created.
|
||||
@# The Go service rewrites URLs at startup via RewriteConfigURLs, so we start
|
||||
@# from the original Bose URLs rather than whatever update-urls.sh produced.
|
||||
@[ ! -f "$(STOCKHOLM_DIR)/json/backup.json" ] || \
|
||||
cp "$(STOCKHOLM_DIR)/json/backup.json" "$(STOCKHOLM_DIR)/json/config.json"
|
||||
@# Patch browse.js: guard against empty browse-path array so that
|
||||
@# funcObj.browse.getPath() returning undefined does not throw when the user
|
||||
@# has not browsed yet (causes "Now playing error: topLevel" console spam and
|
||||
@# aborts the now-playing update handler).
|
||||
@sed -i.bak \
|
||||
-e 's/: (l()\.topLevel/: ((l() || {}).topLevel/' \
|
||||
-e 's/var a = l()\.topLevel,/var a = (l() || {}).topLevel,/' \
|
||||
-e 's/E() === 0 || funcObj\.browse\.getPath()\.topLevel/E() === 0 || (funcObj.browse.getPath() || {}).topLevel/' \
|
||||
"$(STOCKHOLM_DIR)/js/browse.js" && \
|
||||
rm -f "$(STOCKHOLM_DIR)/js/browse.js.bak"
|
||||
@# Patch bridge JS: replace hardcoded /api/* paths with __stockholmBase-prefixed
|
||||
@# versions so the bridge works when Stockholm is mounted under a base path.
|
||||
@# browser_http_proxy.js declares the proxy URL as a top-level constant;
|
||||
@# without patching it, requests from a /stockholm/* page hit /api/http-proxy
|
||||
@# directly and 404 because the proxy is mounted under the base path.
|
||||
@# Also fix resolveWebviewUrl to include the base path when resolving relative URLs.
|
||||
@python3 scripts/patch-stockholm-bridge.py \
|
||||
"$(STOCKHOLM_DIR)/js/browser_http_proxy.js" \
|
||||
"$(STOCKHOLM_DIR)/js/browser_native_bridge.js" \
|
||||
"$(STOCKHOLM_DIR)/js/app_comm.js" \
|
||||
"$(STOCKHOLM_DIR)/setup/js/app_comm.js"
|
||||
@echo "Stockholm frontend prepared at $(STOCKHOLM_DIR)"
|
||||
|
||||
docker-run-host:
|
||||
@echo "Running Docker container..."
|
||||
@echo "Note: --network host is used for discovery (Linux only). For macOS/Windows use port mapping."
|
||||
@@ -362,6 +456,7 @@ help:
|
||||
@echo " dev - Build and show CLI help"
|
||||
@echo " dev-service - Build and run service locally"
|
||||
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
|
||||
@echo " dev-service-stockholm - Build and run service with Stockholm frontend (requires prior 'make prepare-stockholm')"
|
||||
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
|
||||
@echo " dev-discover - Build and run device discovery"
|
||||
@echo " dev-info - Build and get device info (HOST=ip required)"
|
||||
@@ -386,6 +481,9 @@ help:
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " docker-run-host - Run container with host networking (Linux discovery)"
|
||||
@echo " docker-run-ports - Run container with port mapping (macOS/Windows/No discovery)"
|
||||
@echo " build-stockholm-image - Build Stockholm prep image (requires Docker + internet)"
|
||||
@echo " prepare-stockholm - Extract and patch Stockholm frontend (requires build-stockholm-image"
|
||||
@echo " and stockholm_zip/stockholm.zip; see docs/stockholm-port-guide.md)"
|
||||
@echo " help - Show this help message"
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> Independent project. Not affiliated with or endorsed by Bose Corporation.
|
||||
> Independent project. **Not affiliated with, endorsed by, sponsored
|
||||
> by, or otherwise connected to Bose Corporation.** See
|
||||
> [Disclaimer](#disclaimer) for the full statement.
|
||||
|
||||
## Context: Cloud Shutdown
|
||||
|
||||
@@ -20,7 +22,7 @@ See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVI
|
||||
|
||||
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
|
||||
|
||||
If you want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
|
||||
If you don't want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
|
||||
|
||||
**Two scenarios:**
|
||||
|
||||
@@ -122,8 +124,35 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests welcome — code, documentation, bug reports, and feature ideas all land in the same place. By submitting a contribution you agree to license it under MIT. For significant changes please open an issue first to discuss the approach. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
|
||||
|
||||
## Support the project
|
||||
|
||||
If this toolkit kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation — everything in this repo stays MIT regardless.
|
||||
|
||||
[](https://github.com/sponsors/gesellix)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an independent open-source project. **Bose** and **SoundTouch**
|
||||
are registered trademarks of Bose Corporation in the United States and
|
||||
other countries. This project is **not affiliated with, endorsed by,
|
||||
sponsored by, or otherwise connected to** Bose Corporation.
|
||||
|
||||
The toolkit exists solely to restore functionality of Bose SoundTouch
|
||||
speakers after the official cloud service shutdown on May 6, 2026.
|
||||
Reverse engineering for the sole purpose of interoperability is
|
||||
permitted under [EU Directive 2009/24/EC, Article 6](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32009L0024)
|
||||
("Decompilation"), and comparable provisions in other jurisdictions.
|
||||
|
||||
The optional Stockholm frontend integration (`STOCKHOLM_DIR`) requires
|
||||
the user to supply the Stockholm web-app sources themselves; no Bose
|
||||
code is redistributed in this repository.
|
||||
|
||||
The software is provided AS IS, without warranty. Use at your own risk.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
SoundTouch is a trademark of Bose Corporation.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package main — `soundtouch-cli source tunein` subcommand.
|
||||
//
|
||||
// Convenience shortcut for the verbose `source content --source TUNEIN
|
||||
// --type … --location …` pattern. Picks the right Type + location template
|
||||
// from the TuneIn guide-ID prefix, optionally fetches name + artwork from
|
||||
// TuneIn's describe endpoint, then calls the same SelectContentItem path
|
||||
// the generic `source content` command uses.
|
||||
//
|
||||
// Implements #226.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// tuneInKind captures the three guide-ID shapes the SoundTouch firmware
|
||||
// distinguishes; each picks a different Bose `/v1/playback/...` location
|
||||
// template and a different ContentItem Type.
|
||||
type tuneInKind struct {
|
||||
flag string // CLI flag name (`station`, `episode`, `program`)
|
||||
prefix string // single-letter guide-ID prefix (`s`, `e`, `p`)
|
||||
location string // printf template, %s = guide ID
|
||||
itemType string // ContentItem.Type the speaker expects
|
||||
humanName string // user-facing kind label for log lines
|
||||
}
|
||||
|
||||
var tuneInKinds = []tuneInKind{
|
||||
{flag: "station", prefix: "s", location: "/v1/playback/station/%s", itemType: "stationurl", humanName: "live station"},
|
||||
{flag: "episode", prefix: "e", location: "/v1/playback/episode/%s", itemType: "stationurl", humanName: "podcast episode"},
|
||||
{flag: "program", prefix: "p", location: "/v1/playback/episodes/%s", itemType: "tracklisturl", humanName: "podcast program"},
|
||||
}
|
||||
|
||||
// resolveTuneInKind picks a kind from the CLI flags. Exactly one of
|
||||
// --station / --episode / --program must be set, OR --id with a prefix we
|
||||
// recognise. Returns the kind plus the bare guide ID.
|
||||
func resolveTuneInKind(c *cli.Context) (*tuneInKind, string, error) {
|
||||
// Explicit kind flags take precedence over --id.
|
||||
var picked *tuneInKind
|
||||
|
||||
var id string
|
||||
|
||||
for i, k := range tuneInKinds {
|
||||
v := c.String(k.flag)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if picked != nil {
|
||||
return nil, "", fmt.Errorf("only one of --station, --episode, --program may be set")
|
||||
}
|
||||
|
||||
picked = &tuneInKinds[i]
|
||||
id = v
|
||||
}
|
||||
|
||||
if picked != nil {
|
||||
return picked, strings.TrimSpace(id), nil
|
||||
}
|
||||
|
||||
// Fall back to --id with prefix auto-detect.
|
||||
raw := strings.TrimSpace(c.String("id"))
|
||||
if raw == "" {
|
||||
return nil, "", fmt.Errorf("one of --station, --episode, --program, or --id is required")
|
||||
}
|
||||
|
||||
if raw == "" {
|
||||
return nil, "", fmt.Errorf("--id is empty")
|
||||
}
|
||||
|
||||
for i, k := range tuneInKinds {
|
||||
if strings.HasPrefix(raw, k.prefix) {
|
||||
return &tuneInKinds[i], raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("--id %q has no recognised TuneIn prefix; use --station/--episode/--program explicitly", raw)
|
||||
}
|
||||
|
||||
// playTuneIn is the action wired into `soundtouch-cli source tunein`.
|
||||
func playTuneIn(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kind, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := c.String("name")
|
||||
artwork := c.String("artwork")
|
||||
|
||||
// Optional metadata enrichment — only fetch if the user hasn't already
|
||||
// supplied both, and they haven't asked us to skip it.
|
||||
if !c.Bool("no-lookup") && (name == "" || artwork == "") {
|
||||
fetchedName, fetchedLogo, lookupErr := bmx.TuneInDescribeMeta(id)
|
||||
if lookupErr != nil {
|
||||
// Non-fatal: the speaker can resolve the title itself; just
|
||||
// note the failure so an operator sees what went wrong.
|
||||
fmt.Printf(" Note: TuneIn describe lookup failed (%v); proceeding without enrichment.\n", lookupErr)
|
||||
} else {
|
||||
if name == "" {
|
||||
name = fetchedName
|
||||
}
|
||||
|
||||
if artwork == "" {
|
||||
artwork = fetchedLogo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
// Fall back to a sensible non-empty default so the speaker's
|
||||
// now-playing UI doesn't show a blank source label.
|
||||
name = "TuneIn"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: kind.itemType,
|
||||
Location: fmt.Sprintf(kind.location, id),
|
||||
ItemName: name,
|
||||
ContainerArt: artwork,
|
||||
IsPresetable: true,
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Playing TuneIn "+kind.humanName, clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" ID: %s\n", id)
|
||||
fmt.Printf(" Location: %s\n", contentItem.Location)
|
||||
fmt.Printf(" Type: %s\n", contentItem.Type)
|
||||
fmt.Printf(" Name: %s\n", contentItem.ItemName)
|
||||
|
||||
if contentItem.ContainerArt != "" {
|
||||
fmt.Printf(" Artwork: %s\n", contentItem.ContainerArt)
|
||||
}
|
||||
|
||||
if err := client.SelectContentItem(contentItem); err != nil {
|
||||
return fmt.Errorf("failed to select TuneIn content: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("TuneIn content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// newCtx wires a *cli.Context with the kind-selection flags the resolver
|
||||
// reads, plus whatever values the test wants set. Empty-string values are
|
||||
// the default (flag not provided).
|
||||
func newCtx(t *testing.T, kv map[string]string) *cli.Context {
|
||||
t.Helper()
|
||||
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
for _, name := range []string{"station", "episode", "program", "id"} {
|
||||
fs.String(name, "", "")
|
||||
}
|
||||
|
||||
for k, v := range kv {
|
||||
if err := fs.Set(k, v); err != nil {
|
||||
t.Fatalf("fs.Set(%q, %q): %v", k, v, err)
|
||||
}
|
||||
}
|
||||
|
||||
return cli.NewContext(nil, fs, nil)
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Station(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"station": "s14991"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "station" || k.itemType != "stationurl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "s14991" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Episode(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"episode": "e789012"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "episode" || k.itemType != "stationurl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "e789012" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
|
||||
if !strings.Contains(k.location, "/v1/playback/episode/") {
|
||||
t.Errorf("wrong location template: %q", k.location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Program(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"program": "p123456"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "program" || k.itemType != "tracklisturl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "p123456" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
|
||||
if !strings.Contains(k.location, "/v1/playback/episodes/") {
|
||||
t.Errorf("wrong location template: %q", k.location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_IDPrefixAutoDetect(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
wantFlag string
|
||||
}{
|
||||
{"s14991", "station"},
|
||||
{"e789012", "episode"},
|
||||
{"p123456", "program"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.id, func(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"id": tc.id})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != tc.wantFlag {
|
||||
t.Errorf("auto-detect picked %q; want %q", k.flag, tc.wantFlag)
|
||||
}
|
||||
|
||||
if id != tc.id {
|
||||
t.Errorf("id round-tripped wrong: got %q want %q", id, tc.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_NoFlags(t *testing.T) {
|
||||
c := newCtx(t, nil)
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no flags are set")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "required") {
|
||||
t.Errorf("error message should mention required flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_ConflictingFlags(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"station": "s14991", "episode": "e789012"})
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when conflicting flags are set")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "only one of") {
|
||||
t.Errorf("error message should mention exclusivity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_UnknownPrefix(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"id": "x999"})
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown ID prefix")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "no recognised TuneIn prefix") {
|
||||
t.Errorf("error message should explain prefix mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1054,6 +1054,43 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tunein",
|
||||
Usage: "Play a TuneIn station / episode / program by guide ID (#226)",
|
||||
Action: playTuneIn,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "station",
|
||||
Usage: "TuneIn live-station guide ID (e.g. s14991)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "episode",
|
||||
Usage: "TuneIn single-episode guide ID (e.g. e789012)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "program",
|
||||
Usage: "TuneIn podcast/program guide ID (e.g. p123456)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "TuneIn guide ID; kind auto-detected from s/e/p prefix",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Override the display name (skips name lookup)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Override the artwork URL (skips artwork lookup)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-lookup",
|
||||
Usage: "Skip the TuneIn describe lookup; send the bare ContentItem",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "availability",
|
||||
Usage: "Show service availability",
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -373,6 +374,17 @@ func main() {
|
||||
Value: "local",
|
||||
EnvVars: []string{"PREFERRED_SOURCE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-dir",
|
||||
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
|
||||
EnvVars: []string{"STOCKHOLM_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-base-path",
|
||||
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
|
||||
Value: "/stockholm",
|
||||
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
@@ -468,7 +480,20 @@ func main() {
|
||||
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
r := setupRouter(server)
|
||||
var stockholmHandler *stockholm.Handler
|
||||
|
||||
if config.stockholmDir != "" {
|
||||
sh, shErr := stockholm.New(config.stockholmDir, config.dataDir, config.serverURL, config.stockholmBasePath)
|
||||
if shErr != nil {
|
||||
log.Printf("Warning: Failed to initialise Stockholm handler: %v", shErr)
|
||||
} else {
|
||||
stockholmHandler = sh
|
||||
|
||||
log.Printf("Stockholm frontend enabled from %s", config.stockholmDir)
|
||||
}
|
||||
}
|
||||
|
||||
r := setupRouter(server, stockholmHandler)
|
||||
|
||||
log.Printf("Go service starting on %s", config.serverURL)
|
||||
|
||||
@@ -552,6 +577,8 @@ type serviceConfig struct {
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
preferredSource string
|
||||
stockholmDir string
|
||||
stockholmBasePath string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
@@ -628,6 +655,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
migrationEnabled := c.Bool("migration-enabled")
|
||||
migrationDryRun := c.Bool("migration-dry-run")
|
||||
preferredSource := c.String("preferred-source")
|
||||
stockholmDir := c.String("stockholm-dir")
|
||||
stockholmBasePath := c.String("stockholm-base-path")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
@@ -666,6 +695,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
preferredSource: preferredSource,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,7 +883,7 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
}()
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
|
||||
@@ -881,8 +912,11 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
// reach it without a reboot.
|
||||
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = "/media/favicon-braille.svg"
|
||||
server.HandleMedia()(w, r)
|
||||
// The favicon lives in the embedded web/img bundle, not under
|
||||
// static/media — HandleMedia would 404. HandleWeb serves from
|
||||
// webFS at its native path.
|
||||
r.URL.Path = "/web/img/favicon-braille.svg"
|
||||
server.HandleWeb()(w, r)
|
||||
})
|
||||
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
@@ -919,6 +953,16 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
|
||||
|
||||
// SiriusXM lives at the top level by the same convention. bmx_services.json
|
||||
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
|
||||
// (no /bmx/ prefix), so speakers reach this exact path under either
|
||||
// migration mode. The bare path returns the service descriptor (matches
|
||||
// soundcork main.py:805); sub-paths advertised by the descriptor's _links
|
||||
// (/availability, /navigate, /token, /logout) currently log + 404 so
|
||||
// future implementation work has visibility into real speaker calls.
|
||||
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter", server.HandleSiriusXMLiveAdapter)
|
||||
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*", server.HandleSiriusXMLiveAdapterSubpath)
|
||||
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
r.Route("/streaming", func(r chi.Router) {
|
||||
@@ -1140,8 +1184,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the management API.
|
||||
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
|
||||
if stockholmHandler != nil {
|
||||
r.Get("/*", stockholmHandler.HandleStatic)
|
||||
r.Get("/", stockholmHandler.HandleStatic)
|
||||
}
|
||||
})
|
||||
|
||||
if stockholmHandler != nil {
|
||||
stockholmHandler.Mount(r)
|
||||
}
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
return r
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func TestPrintRoutes(t *testing.T) {
|
||||
// Initialize a minimal server to get the router
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server)
|
||||
r := setupRouter(server, nil)
|
||||
|
||||
var routes []string
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
@@ -128,7 +128,7 @@ func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
r := setupRouter(server)
|
||||
r := setupRouter(server, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
@@ -32,6 +36,8 @@ GET /bmx/tunein/v1/playback/station/{stationID} handlers.(
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
|
||||
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
|
||||
GET /docs/* handlers.(*Server).HandleDocs-fm
|
||||
@@ -89,8 +95,14 @@ GET /streaming/sourceproviders handlers.(
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
@@ -103,6 +115,8 @@ POST /bmx/tunein/v1/favorite/{stationID} handlers.(
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
POST /core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
|
||||
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
|
||||
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
|
||||
@@ -155,7 +169,11 @@ POST /streaming/support/customersupport handlers.(
|
||||
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
|
||||
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
|
||||
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
accounts/
|
||||
backend/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
|
||||
@@ -105,3 +105,4 @@
|
||||
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
* [Stockholm Port Guide](stockholm-port-guide.md)
|
||||
|
||||
@@ -154,26 +154,30 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
|
||||
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
|
||||
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
@@ -188,6 +192,57 @@ SERVER_URL=https://my-soundtouch.example.com soundtouch-service --port 443
|
||||
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
|
||||
```
|
||||
|
||||
## Stockholm Frontend
|
||||
|
||||
The Stockholm frontend is the patched Bose SoundTouch app UI served directly by the service. When enabled, opening `http://<server>:8000` in a browser shows the full app interface, which communicates with your speakers via the local service instead of Bose's cloud.
|
||||
|
||||
### Getting the Stockholm files
|
||||
|
||||
The Stockholm UI files are not bundled in this repository — you supply them from [krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app). See that project's README for how to obtain the `stockholm.zip`. Once you have it:
|
||||
|
||||
```bash
|
||||
# 1. Place stockholm.zip in stockholm_zip/
|
||||
mkdir -p stockholm_zip
|
||||
cp /path/to/stockholm.zip stockholm_zip/
|
||||
|
||||
# 2. Build the Docker image that applies the patches
|
||||
make build-stockholm-image
|
||||
|
||||
# 3. Extract and patch the frontend into ./stockholm/
|
||||
make prepare-stockholm
|
||||
```
|
||||
|
||||
The `./stockholm/` directory is now ready to use.
|
||||
|
||||
### Enabling the Stockholm UI
|
||||
|
||||
Pass the directory to the service at startup:
|
||||
|
||||
```bash
|
||||
# Development (recommended): builds the service and runs it with
|
||||
# Stockholm enabled, checking that prepare-stockholm has run.
|
||||
make dev-service-stockholm
|
||||
|
||||
# Binary
|
||||
soundtouch-service --stockholm-dir ./stockholm
|
||||
|
||||
# Environment variable
|
||||
STOCKHOLM_DIR=./stockholm soundtouch-service
|
||||
|
||||
# Docker Compose — add to the environment section of docker-compose.yml
|
||||
# STOCKHOLM_DIR=/app/stockholm
|
||||
# and mount the stockholm/ directory into the container
|
||||
```
|
||||
|
||||
### Stockholm environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `STOCKHOLM_DIR` | Path to the extracted Stockholm frontend (enables the UI) |
|
||||
| `MARGE_URL` | Override the streaming/marge URL written into `config.json`. Defaults to `SERVER_URL`. Only set this to `SERVER_URL/marge` when routing through a soundcork backend. |
|
||||
| `MARGE_AUTH_TOKEN` | Pre-seed the session auth token so the first app launch skips the login screen |
|
||||
| `MARGE_ACCOUNT_ID` | Pre-seed the account ID — device discovery will only show speakers on this account |
|
||||
|
||||
## Device Migration
|
||||
|
||||
### Understanding Migration
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
# Stockholm Backend — Port Guide for Bose-SoundTouch (Go)
|
||||
|
||||
This document describes everything needed to integrate the
|
||||
[krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app)
|
||||
functionality into the Go service. It is written as a reference; nothing here
|
||||
implies a specific file layout or package structure.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [What needs porting](#1-what-needs-porting)
|
||||
2. [What becomes obsolete](#2-what-becomes-obsolete)
|
||||
3. [Startup: Stockholm frontend preparation](#3-startup-stockholm-frontend-preparation)
|
||||
4. [Native bridge — appSend / runQueue](#4-native-bridge--appsend--runqueue)
|
||||
5. [State persistence — native-state.json](#5-state-persistence--native-statejson)
|
||||
6. [HTTP proxy — /api/http-proxy](#6-http-proxy--apihttp-proxy)
|
||||
7. [Browser bootstrap injection](#7-browser-bootstrap-injection)
|
||||
8. [SSDP discovery](#8-ssdp-discovery)
|
||||
9. [Config file structure (stockholm/json/config.json)](#9-config-file-structure-stockholmjsonconfigjson)
|
||||
10. [Backend config (backend-config.json)](#10-backend-config-backend-configjson)
|
||||
11. [Running as a plain process (no Docker)](#11-running-as-a-plain-process-no-docker)
|
||||
|
||||
---
|
||||
|
||||
## 1. What needs porting
|
||||
|
||||
| Component | Java class / file | Notes |
|
||||
|-----------------------------------------------|---------------------------------------|------------------------------------------------------|
|
||||
| Stockholm zip extraction + patch application | `docker-entrypoint.sh` | Shell; can be Go at startup |
|
||||
| URL rewriting in `stockholm/json/config.json` | `update-urls.sh` | Shell + `jq`/`sed`; can be Go |
|
||||
| Native bridge | `NativeBridgeService` | Core; per-tab message queue |
|
||||
| State persistence | `NativeBridgeService` (file I/O) | JSON file read/written on every `setData` |
|
||||
| HTTP proxy | `HttpProxyService` | CORS proxy + cloud header injection |
|
||||
| Browser bootstrap injection | `BackendApplication` (static handler) | Injects `<script>` into `index.html` |
|
||||
| SSDP speaker + media-server discovery | `SsdpDiscoveryService` | Already partially in Bose-SoundTouch |
|
||||
| Config reading | `SoundcorkDataService` | Reads `config.json` + `override.json` |
|
||||
| Backend config | `BackendConfig` | Single JSON file, only `frontendLoggingLevel` so far |
|
||||
|
||||
---
|
||||
|
||||
## 2. What becomes obsolete
|
||||
|
||||
When Bose-SoundTouch serves Stockholm directly, the following env vars and
|
||||
concepts collapse because the Go service knows its own URLs:
|
||||
|
||||
| Variable | Why it disappears |
|
||||
|------------------------------------|--------------------------------------------|
|
||||
| `BACKEND_URL` | Go service knows its own base URL |
|
||||
| `STREAMING_URL` | Same — the marge path is internal |
|
||||
| `AUTH_SERVICE_URL` | Same — marge is a local handler |
|
||||
| `BACKEND_BIND_IP` / `BACKEND_PORT` | Replaced by existing `PORT` / `HTTPS_PORT` |
|
||||
| `update-urls.sh` | Config rewriting becomes Go startup logic |
|
||||
| Custom CA cert via `keytool` | Replaced by Bose-SoundTouch `certmanager` |
|
||||
|
||||
What does **not** disappear:
|
||||
|
||||
- `MARGE_AUTH_TOKEN` / `MARGE_ACCOUNT_ID` — seeding initial session state
|
||||
- Stockholm zip + versioned patch files — still needed as assets
|
||||
- `PREFERRED_DEVICES` and other existing Bose-SoundTouch config
|
||||
|
||||
---
|
||||
|
||||
## 3. Startup: Stockholm frontend preparation
|
||||
|
||||
### 3a. Zip extraction
|
||||
|
||||
Source file: `docker-entrypoint.sh:prepare_stockholm()`
|
||||
|
||||
Look for `stockholm/index.html`. If absent:
|
||||
|
||||
1. Find the zip in `stockholm_zip/stockholm.zip` (preferred) or `stockholm.zip`
|
||||
alongside the binary.
|
||||
2. Extract the zip into `stockholm/`.
|
||||
|
||||
### 3b. Versioned patch application
|
||||
|
||||
Patch files are named `stockholm-changes_v<N>.patch` and applied in ascending
|
||||
order. The set is scanned dynamically at preparation time; today the upstream
|
||||
project ships v1 (1 153 lines), v2 (1 475 lines), v3 (44 lines, `now_play.js`
|
||||
fix), and v4 (68 lines, `app_comm.js` clientId polish). New versions added
|
||||
upstream are picked up automatically — our code does not hardcode a list.
|
||||
|
||||
A marker file `stockholm/.soundcork-stockholm-app.json` tracks the last applied
|
||||
version:
|
||||
```json
|
||||
{"project":"soundcork-stockholm-app","patchVersion":2}
|
||||
```
|
||||
|
||||
Algorithm:
|
||||
|
||||
1. Read `patchVersion` from the marker (default 0).
|
||||
2. For each `stockholm-changes_v<N>.patch` with N > current version, in order:
|
||||
- Strip hunks that don't touch `stockholm/` paths (the patch files include
|
||||
README and self-referential hunks).
|
||||
- Dry-run `patch -p1 -R` (reverse) to test if it's already applied.
|
||||
- Dry-run `patch -p1` (forward) to test if it can apply.
|
||||
- Apply with `patch -p1 --batch`.
|
||||
- Write the marker for version N.
|
||||
3. For v1 only, run `prettier --write "stockholm/**/*.js"` before patching
|
||||
(the patch was generated against formatted source).
|
||||
|
||||
The `patch` and `prettier` (npm) binaries are required. In a container image
|
||||
these are install-time dependencies. For a plain binary distribution they must
|
||||
be present on the host.
|
||||
|
||||
### 3c. Copy update-urls.sh into place
|
||||
|
||||
Copy `update-urls.sh` to `stockholm/json/update-urls.sh` after extraction.
|
||||
The script is called from that directory so relative paths work.
|
||||
|
||||
### 3d. Rewrite config.json URLs (replaces update-urls.sh)
|
||||
|
||||
`stockholm/json/config.json` stores most values base64-encoded under a
|
||||
`"default"` key (`d0`…`d13`). `update-urls.sh` decodes, rewrites with `sed`,
|
||||
and re-encodes.
|
||||
|
||||
When the Go service knows its own URLs at startup, it can do this in-process:
|
||||
|
||||
```
|
||||
fields to rewrite (sed substitutions in the shell script):
|
||||
streaming.bose.com → STREAMING_URL (default: BACKEND_URL, soundcork: BACKEND_URL/marge)
|
||||
events.api.bosecm.com → BACKEND_URL
|
||||
content.api.bose.io → BACKEND_URL
|
||||
worldwide.bose.com → BACKEND_URL
|
||||
downloads.bose.com → BACKEND_URL
|
||||
d6 field → AUTH_SERVICE_URL (set via jq, not sed)
|
||||
```
|
||||
|
||||
The Go equivalent:
|
||||
1. Read `config.json`, base64-decode each value in `default`.
|
||||
2. Replace the hostnames above.
|
||||
3. Set `default.d6` to the auth service URL.
|
||||
4. Re-encode all values in `default` as base64.
|
||||
5. Write back.
|
||||
|
||||
---
|
||||
|
||||
## 4. Native bridge — appSend / runQueue
|
||||
|
||||
Source: `NativeBridgeService.java`
|
||||
|
||||
Stockholm communicates with the native layer through two HTTP endpoints. The
|
||||
bridge emulates the Android `Native` object.
|
||||
|
||||
### Endpoints
|
||||
|
||||
```
|
||||
POST /api/native/appSend?clientId=<id> (or X-Stockholm-Client-Id header)
|
||||
GET /api/native/runQueue?clientId=<id>
|
||||
```
|
||||
|
||||
`clientId` is a per-browser-tab identifier. Falls back to `"default"`.
|
||||
|
||||
### appSend request body
|
||||
|
||||
JSON:
|
||||
```json
|
||||
{"method":"<name>","params":{...},"id":<number or null>}
|
||||
```
|
||||
|
||||
### runQueue response body
|
||||
|
||||
```json
|
||||
{"messages": [<message>, ...] | null}
|
||||
```
|
||||
|
||||
Each message is one of:
|
||||
|
||||
**Callback result** (response to a `getData`, `getConstant`, etc.):
|
||||
```json
|
||||
{"result":<value>,"error":<value or null>,"id":<id from request>}
|
||||
```
|
||||
|
||||
**Push method** (unsolicited, e.g. device discovery results):
|
||||
```json
|
||||
{"method":"devices","params":[...],"id":null}
|
||||
```
|
||||
|
||||
### Supported methods
|
||||
|
||||
| Method | Action |
|
||||
|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `locale`, `htmlReady`, `stopHrmsUpdates` | No-op |
|
||||
| `log` | Log `params.msg` at debug level |
|
||||
| `setData` | Store `params.name` → `params.value` in state; persist to disk |
|
||||
| `getData` | Return state value for `params.name`; empty string if absent |
|
||||
| `getLanStatus` | Return `{"result":true,"error":null,"id":<id>}` |
|
||||
| `getTimeZone` | Return `{"result":{"timezoneInfo":"<IANA zone>","timeFormat":"TIME_FORMAT_24HOUR_ID"}}` |
|
||||
| `getLegalDocPath` | Return path string (see below) |
|
||||
| `getConstant` | Return `state["constant.<name>"]`; default for `"kilo"` is `"a7928d7b43dcd49f0af31e5aeed26458"` |
|
||||
| `canPerformAutoAPSetup` | Return `{"result":{"permission":false,"location":false}}` |
|
||||
| `getDeviceList` | Run SSDP renderer discovery async; push incremental `"devices"` messages |
|
||||
| `getHrmsList` | Run SSDP server discovery async; push `"servers"` message |
|
||||
| `getNetStats`, `getSSIDList`, `setSSID`, `updateSetting`, `oauth`, `downloadNewGui`, `installNewGui`, `sendLogs`, `socketCreate`, `socketSend`, `socketClose` | Return error `"unsupported"` |
|
||||
|
||||
**getLegalDocPath logic:**
|
||||
```
|
||||
type=lcns → "legal/platform_license.txt"
|
||||
type=<blank> → "legal/eula_en.txt"
|
||||
type=<type>,lang=<lang> → "legal/<type>_<lang>.txt" (lang defaults to "en")
|
||||
```
|
||||
|
||||
### Async discovery pattern
|
||||
|
||||
`getDeviceList` and `getHrmsList` are fired asynchronously. Each discovered
|
||||
device is pushed to the client queue immediately via `"devices"` / `"servers"`
|
||||
method messages before the discovery is complete. The frontend polls
|
||||
`/api/native/runQueue` continuously, so results arrive as they come in.
|
||||
|
||||
### Queue structure
|
||||
|
||||
One deque per `clientId`. `appSend` appends; `runQueue` drains the whole deque
|
||||
atomically and returns all pending messages.
|
||||
|
||||
### State seeding from environment
|
||||
|
||||
On startup, read these env vars and write to state if present:
|
||||
|
||||
| Env var | State key |
|
||||
|----------------------------------------|------------------|
|
||||
| `MARGE_AUTH_TOKEN` or `margeAuthToken` | `margeAuthToken` |
|
||||
| `MARGE_ACCOUNT_ID` or `margeAccountID` | `margeAccountID` |
|
||||
|
||||
Also seed on first run:
|
||||
|
||||
| State key | Value |
|
||||
|----------------------|-----------------------------------------------------------------|
|
||||
| `guid` | Random UUID (hex, no dashes) |
|
||||
| `deviceGuid` | Same UUID as `guid` |
|
||||
| `nativeFrameVersion` | Short version prefix extracted from `bose_app` in `config.json` |
|
||||
| `frame_version` | Full version from `bose_app` |
|
||||
| `authServer` | `"0"` |
|
||||
| `constant.kilo` | `"a7928d7b43dcd49f0af31e5aeed26458"` |
|
||||
|
||||
---
|
||||
|
||||
## 5. State persistence — native-state.json
|
||||
|
||||
Source: `NativeBridgeService.loadState()` / `persistState()`
|
||||
|
||||
File path (relative to workspace root): `backend/state/native-state.json`
|
||||
|
||||
Format: flat JSON object, all values are strings.
|
||||
|
||||
```json
|
||||
{
|
||||
"guid": "abc123...",
|
||||
"deviceGuid": "abc123...",
|
||||
"frame_version": "27.0.13",
|
||||
"nativeFrameVersion": "27.0.13",
|
||||
"authServer": "0",
|
||||
"margeAuthToken": "<token>",
|
||||
"margeAccountID": "1234567",
|
||||
"overrideMargeURL": "https://...",
|
||||
"overrideUpdateURL": "https://...",
|
||||
"constant.kilo": "a7928d7b43dcd49f0af31e5aeed26458",
|
||||
... (arbitrary keys from setData calls)
|
||||
}
|
||||
```
|
||||
|
||||
Written on every `setData` call and on initial seeding. Read once at startup.
|
||||
|
||||
---
|
||||
|
||||
## 6. HTTP proxy — /api/http-proxy
|
||||
|
||||
Source: `HttpProxyService.java`
|
||||
|
||||
Stockholm makes all cloud API calls through this proxy to work around browser
|
||||
CORS restrictions.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
<ANY METHOD> /api/http-proxy?url=<url-encoded target URL>
|
||||
```
|
||||
|
||||
### Header filtering
|
||||
|
||||
**Blocked outbound (not forwarded to target):**
|
||||
```
|
||||
access-control-request-headers, access-control-request-method, connection,
|
||||
content-length, cookie, forwarded, host, http2-settings, keep-alive, origin,
|
||||
proxy-authenticate, proxy-authorization, referer, sec-ch-ua, sec-ch-ua-mobile,
|
||||
sec-ch-ua-platform, sec-fetch-dest, sec-fetch-mode, sec-fetch-site,
|
||||
sec-fetch-user, te, trailer, transfer-encoding, upgrade, x-forwarded-for,
|
||||
x-forwarded-host, x-forwarded-port, x-forwarded-proto, x-real-ip,
|
||||
x-requested-with
|
||||
```
|
||||
|
||||
**Blocked inbound (not relayed to browser):**
|
||||
```
|
||||
access-control-allow-credentials, access-control-allow-headers,
|
||||
access-control-allow-methods, access-control-allow-origin,
|
||||
access-control-expose-headers, access-control-max-age, connection,
|
||||
content-length, keep-alive, proxy-authenticate, proxy-authorization,
|
||||
set-cookie, set-cookie2, te, trailer, transfer-encoding, upgrade
|
||||
```
|
||||
|
||||
Also block HTTP/2 pseudo-headers (names starting with `:`).
|
||||
|
||||
Always add `Cache-Control: no-store` to the response.
|
||||
|
||||
### Backend-injected headers
|
||||
|
||||
Injected only if not already present in the request.
|
||||
|
||||
**BMX targets** (host is `content.api.bose.io`, `*.apigee.net`,
|
||||
`bose-prod.apigee.net`, `test.content.api.bose.io`):
|
||||
```
|
||||
x-bmx-api-key: <encryptedBmxToken from config.json d7>
|
||||
x-software-version: <bose_app version>
|
||||
```
|
||||
|
||||
**Marge targets** (host ends with `.bose.com` or `.apigee.net` AND path
|
||||
contains `/streaming/` or `/customer/`):
|
||||
```
|
||||
Accept: application/vnd.bose.streaming-v<N>+xml
|
||||
(or customer variant if path contains /customer/)
|
||||
Content-Type: same as Accept
|
||||
ClientType: SOUNDTOUCH_COMPUTER_APP
|
||||
GUID: <guid from state>
|
||||
version_NativeFrameVersion: <nativeFrameVersion from state>
|
||||
version_StockholmVersion: <bose_app version>
|
||||
version_ProtocolVersion: <bose_protocol version>
|
||||
<margeServerKeyHeader>: <margeServerKey> (if config d13/d10 non-empty)
|
||||
Authorization: <margeAuthToken> (not injected on login/environment endpoints)
|
||||
```
|
||||
|
||||
Authorization is **not** injected for these paths:
|
||||
- `*/streaming/account/login`
|
||||
- `/streaming/account` or `/streaming/account/`
|
||||
- `*/streaming/account/email/*/environment`
|
||||
- `/customer/account/password/email/*`
|
||||
|
||||
### Login retry (environment switching)
|
||||
|
||||
After a login `POST` to `*/streaming/account/login`:
|
||||
|
||||
1. If the response XML contains `<status-code>4033</status-code>` (wrong
|
||||
region), parse the login request body for `<username>` and `<password>`.
|
||||
2. Fetch `GET <same-origin><marge-prefix>/streaming/account/email/<email>/environment`
|
||||
with `Authorization: Basic <base64(email:password)>`.
|
||||
3. Parse the environment response XML for `<streamingURL>` and `<updateURL>`.
|
||||
4. Store both as `overrideMargeURL` / `overrideUpdateURL` in state.
|
||||
5. Retry the original login against the new `streamingURL`.
|
||||
|
||||
Subsequent marge requests are automatically redirected to `overrideMargeURL`
|
||||
via `SoundcorkDataService.overrideTarget()`.
|
||||
|
||||
### Session capture
|
||||
|
||||
After a successful login response (2xx):
|
||||
- Extract `<account id="...">` from the response XML body → store as `margeAccountID`.
|
||||
- Extract `Credentials` response header → store as `margeAuthToken`.
|
||||
|
||||
On any marge response:
|
||||
- If there is a `Refresh` response header, store its value as `margeAuthToken`.
|
||||
|
||||
### Proxy loop detection
|
||||
|
||||
Reject requests whose target URL resolves to the proxy's own
|
||||
`/api/http-proxy` endpoint. Considers both the direct bind address and the
|
||||
externally visible address from `X-Forwarded-Host` / `X-Forwarded-Port` /
|
||||
`Host` headers.
|
||||
|
||||
### Header value sanitisation
|
||||
|
||||
Drop header values that are `null`, `undefined`, or empty string (these can
|
||||
come from the Stockholm JS).
|
||||
|
||||
---
|
||||
|
||||
## 7. Browser bootstrap injection
|
||||
|
||||
Source: `BackendApplication.StaticStockholmHandler`
|
||||
|
||||
On every request to `index.html` or `setup/index.html`, inject a `<script>`
|
||||
block before `</head>`. The script is skipped if `window.StockholmBrowserBootstrap`
|
||||
already exists.
|
||||
|
||||
The injected JSON payload:
|
||||
```json
|
||||
{
|
||||
"authServer": "<0–3, from state>",
|
||||
"guid": "<guid from state>",
|
||||
"nativeVersion": "<frame_version from state>",
|
||||
"frameConfig": {}
|
||||
}
|
||||
```
|
||||
|
||||
The script does four things:
|
||||
1. Patches `window.getURLParams` to return `bootstrap.authServer`, `bootstrap.guid`,
|
||||
and `bootstrap.nativeVersion` for the keys `authServer`, `guid`, and
|
||||
`native_version` when the original function returns null.
|
||||
2. Patches `window.getUserAgentValue` to return `bootstrap.guid` for `_app`
|
||||
when the original returns empty.
|
||||
3. Sets `window.guid`, `window.frame_version`, `window.auth_server` from
|
||||
bootstrap values when they are empty.
|
||||
4. Patches `window.settingsLoad` to merge `bootstrap.frameConfig` into the
|
||||
config object (keys `f<N>` → `d<N>`, base64-encoded, only if currently
|
||||
empty).
|
||||
|
||||
`authServer` is an integer string `"0"`–`"3"`. The Java code normalises to
|
||||
`"0"` for any invalid value.
|
||||
|
||||
### Static file serving
|
||||
|
||||
Serve everything under `stockholm/` for all paths. Content types:
|
||||
|
||||
| Extension | MIME type |
|
||||
|----------------|-----------------------------------------|
|
||||
| `.html` | `text/html; charset=UTF-8` |
|
||||
| `.js` | `application/javascript; charset=UTF-8` |
|
||||
| `.css` | `text/css; charset=UTF-8` |
|
||||
| `.json` | `application/json; charset=UTF-8` |
|
||||
| `.xml` | `application/xml; charset=UTF-8` |
|
||||
| `.svg` | `image/svg+xml` |
|
||||
| `.png` | `image/png` |
|
||||
| `.jpg`/`.jpeg` | `image/jpeg` |
|
||||
| `.gif` | `image/gif` |
|
||||
| `.ttf` | `font/ttf` |
|
||||
| `.otf` | `font/otf` |
|
||||
| `.txt` | `text/plain; charset=UTF-8` |
|
||||
|
||||
Set `Cache-Control: no-store` on all responses.
|
||||
|
||||
For `HEAD` requests send headers only (no body, status -1 in content-length).
|
||||
For 204/304 responses send no body.
|
||||
|
||||
Path traversal: reject any path that resolves outside `stockholm/`.
|
||||
|
||||
### Frontend logging cookie
|
||||
|
||||
Set a `Set-Cookie` header on every static response:
|
||||
- If `frontendLoggingLevel > 0`:
|
||||
`stockholmFrontendLoggingLevel=<level>; Path=/; SameSite=Lax`
|
||||
- Otherwise (clear it):
|
||||
`stockholmFrontendLoggingLevel=; Max-Age=0; Path=/; SameSite=Lax`
|
||||
|
||||
---
|
||||
|
||||
## 8. SSDP discovery
|
||||
|
||||
Source: `SsdpDiscoveryService.java`
|
||||
|
||||
Bose-SoundTouch already has SSDP/UPnP discovery in `pkg/discovery`. The
|
||||
Stockholm bridge needs two specific discovery types with specific result shapes.
|
||||
|
||||
### Renderer discovery (speakers) — `getDeviceList`
|
||||
|
||||
Search target: `urn:schemas-upnp-org:device:MediaRenderer:1`
|
||||
|
||||
For each SSDP response, extract the `Location` header URL, take the `host`
|
||||
part, then fetch `GET http://<host>:8090/info`.
|
||||
|
||||
Parse the XML response:
|
||||
```xml
|
||||
<info deviceID="AA:BB:CC:DD:EE:FF">
|
||||
...
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
...
|
||||
</info>
|
||||
```
|
||||
|
||||
- `deviceID` attribute → `uID` (uppercased)
|
||||
- `margeAccountUUID` element text → `accountId`
|
||||
|
||||
Filter: if `margeAccountID` is set in state, only include speakers whose
|
||||
`margeAccountUUID` matches.
|
||||
|
||||
Result payload per speaker:
|
||||
```json
|
||||
{"uID": "AA:BB:CC:DD:EE:FF", "ip": "192.168.1.10"}
|
||||
```
|
||||
|
||||
Push incremental results as they arrive (push one device at a time via the
|
||||
`"devices"` method message). At the end, if the list is empty, push an empty
|
||||
`"devices"` message.
|
||||
|
||||
### Network interface selection for SSDP
|
||||
|
||||
Priority order: ethernet/en* > wifi/wl* > others.
|
||||
|
||||
Exclude: loopback, virtual, docker, vbox, vmware, hyper-v, bluetooth, teredo,
|
||||
tunnel interfaces.
|
||||
Require: IPv4 address, multicast support, interface up.
|
||||
|
||||
Try each interface in priority order; return results from the first one that
|
||||
gets responses.
|
||||
|
||||
SSDP probe parameters:
|
||||
- Multicast: `239.255.255.250:1900`
|
||||
- 3 probes, 350 ms between probes
|
||||
- 1 250 ms grace period after last probe
|
||||
- `MX: 1`
|
||||
|
||||
### Media server discovery (HRMS) — `getHrmsList`
|
||||
|
||||
Search target: `urn:schemas-upnp-org:device:MediaServer:1`
|
||||
|
||||
No HTTP fetch needed — extract from SSDP response headers only.
|
||||
|
||||
Result payload per server:
|
||||
```json
|
||||
{"uID": "<usn uuid or host:port>", "ip": "<host>", "port": "<port>"}
|
||||
```
|
||||
|
||||
`uID` is the UUID portion of the `USN` header (strip `uuid:` prefix and
|
||||
anything after `::`). Fall back to `host:port` if USN is absent.
|
||||
|
||||
Push all results at once (no incremental push) via `"servers"` method message.
|
||||
|
||||
---
|
||||
|
||||
## 9. Config file structure (stockholm/json/config.json)
|
||||
|
||||
Source: `SoundcorkDataService.java`
|
||||
|
||||
The file has three top-level objects: `app_versions`, `api_versions`, `default`.
|
||||
|
||||
### app_versions
|
||||
|
||||
| Key | Used as |
|
||||
|-----------------|-------------------------------------------------------------------------------|
|
||||
| `bose_app` | `soundcorkAppVersion` — also `x-software-version`, `version_StockholmVersion` |
|
||||
| `bose_protocol` | `protocolVersion` — sent as `version_ProtocolVersion` |
|
||||
|
||||
### api_versions
|
||||
|
||||
| Key | Used as |
|
||||
|------------------|--------------------------------------------------------------------------|
|
||||
| `bose_streaming` | Streaming API version — builds `application/vnd.bose.streaming-v<N>+xml` |
|
||||
| `bose_customer` | Customer API version — builds `application/vnd.bose.customer-v<N>+xml` |
|
||||
|
||||
### default (all values base64-encoded)
|
||||
|
||||
| Field | Content | Used as |
|
||||
|-------|------------------------------|--------------------------------------------------------------|
|
||||
| `d0` | marge base URL | `defaultMargeUrl` (redirected from `streaming.bose.com`) |
|
||||
| `d1` | update base URL | `defaultUpdateUrl` (redirected from `events.api.bosecm.com`) |
|
||||
| `d3` | BMX registry URL | `defaultBmxRegistryUrl` |
|
||||
| `d6` | auth service URL | Written by `update-urls.sh` / `AUTH_SERVICE_URL` |
|
||||
| `d7` | BMX API token | `encryptedBmxToken` — injected as `x-bmx-api-key` |
|
||||
| `d8` | BMX server alt URL | stored but not currently used in header injection |
|
||||
| `d10` | marge server key | injected as `<margeServerKeyHeader>` value on marge requests |
|
||||
| `d13` | marge server key header name | the header name for d10 |
|
||||
|
||||
### override.json
|
||||
|
||||
Sits alongside `config.json` at `stockholm/json/override.json`. Currently only
|
||||
`kilo` is read from it (not used in any live code path yet).
|
||||
|
||||
---
|
||||
|
||||
## 10. Backend config (backend-config.json)
|
||||
|
||||
Source: `BackendConfig.java`
|
||||
|
||||
File path: `backend/config/backend-config.json`
|
||||
|
||||
```json
|
||||
{"frontendLoggingLevel": 2}
|
||||
```
|
||||
|
||||
`frontendLoggingLevel`:
|
||||
- `0` — disable frontend debug logging (clear the cookie)
|
||||
- `> 0` — enable at that level (set cookie to the numeric value)
|
||||
|
||||
The Stockholm JS reads `stockholmFrontendLoggingLevel` from a cookie on load.
|
||||
|
||||
---
|
||||
|
||||
## 11. Running as a plain process (no Docker)
|
||||
|
||||
The entrypoint script does three things beyond launching the JVM. For a plain
|
||||
process, do these steps once before running the binary:
|
||||
|
||||
### Step 1 — extract and patch Stockholm
|
||||
|
||||
```shell
|
||||
# Requires: unzip, patch, npm/prettier@3.8.3
|
||||
unzip stockholm_zip/stockholm.zip -d stockholm
|
||||
npx prettier@3.8.3 --ignore-path /dev/null --write "stockholm/**/*.js"
|
||||
# Apply every stockholm-changes_v<N>.patch that exists, in ascending order.
|
||||
# Today the upstream ships v1..v4; new ones get picked up automatically when
|
||||
# the upstream repo is re-cloned via `make build-stockholm-image`.
|
||||
for patch in stockholm-changes_v*.patch; do
|
||||
patch -p1 --batch < "$patch"
|
||||
done
|
||||
```
|
||||
|
||||
Or run the Docker container once and copy the `stockholm/` directory out.
|
||||
|
||||
### Step 2 — rewrite URLs in config.json
|
||||
|
||||
```shell
|
||||
cd stockholm/json
|
||||
BACKEND_URL=http://localhost:8000 \
|
||||
STREAMING_URL=http://localhost:8000/marge \ # soundcork only
|
||||
AUTH_SERVICE_URL=http://localhost:8000/marge/ \
|
||||
source update-urls.sh
|
||||
cd ../..
|
||||
```
|
||||
|
||||
For Bose-SoundTouch, this step disappears — the Go service rewrites config.json
|
||||
in-process at startup.
|
||||
|
||||
### Step 3 — create state directory
|
||||
|
||||
```shell
|
||||
mkdir -p backend/state
|
||||
```
|
||||
|
||||
### Step 4 — run
|
||||
|
||||
```shell
|
||||
# Java (current):
|
||||
./gradlew run
|
||||
|
||||
# Go (future):
|
||||
./soundtouch-service # with appropriate env vars
|
||||
```
|
||||
|
||||
The Java `resolveWorkspaceRoot()` searches for a `stockholm/` directory at CWD
|
||||
or one level up. Run from the project root.
|
||||
|
||||
---
|
||||
|
||||
## Patches summary — what the Stockholm JS patches do
|
||||
|
||||
**v1** (the main patch, applied after prettier formatting):
|
||||
|
||||
- `stockholm/index.html` — adds `<meta>` charset and viewport tags
|
||||
- `stockholm/js/app_comm.js` — rewrites `AppComm` to use the HTTP native bridge
|
||||
(`/api/native/appSend` + `/api/native/runQueue`) instead of Android native calls
|
||||
- `stockholm/js/browser_http_proxy.js` — **new file** — implements the
|
||||
`stHttpProxy` function that routes all cloud API calls through `/api/http-proxy`
|
||||
- `stockholm/js/browser_native_bridge.js` — **new file** — implements
|
||||
`window.Native` shim that calls the bridge endpoints
|
||||
- `stockholm/js/main.js` — wires up the browser native bridge on load
|
||||
- `stockholm/setup/index.html` — same charset/viewport fix
|
||||
- `stockholm/setup/js/app_comm.js` — same AppComm bridge rewrite for the setup flow
|
||||
|
||||
**v2** (incremental fixes on top of v1):
|
||||
|
||||
- `stockholm/js/app_comm.js` — additional fixes and multi-tab `clientId` support
|
||||
- `stockholm/js/browser_native_bridge.js` — minor fix
|
||||
- `stockholm/js/main.js` — minor fix
|
||||
- `stockholm/js/marge_comm.js` — fixes marge URL handling
|
||||
- `stockholm/js/presets.js` — minor fix
|
||||
- `stockholm/js/sources.js` — minor fix
|
||||
- `stockholm/setup/js/app_comm.js` — same fixes as main app_comm.js
|
||||
|
||||
**v3** (44 lines):
|
||||
|
||||
- `stockholm/js/now_play.js` — small playback-state guard.
|
||||
|
||||
**v4** (68 lines):
|
||||
|
||||
- `stockholm/js/app_comm.js` — further `clientId` handling polish (localStorage persistence).
|
||||
+258
-11
@@ -23,6 +23,17 @@ const (
|
||||
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
|
||||
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
|
||||
|
||||
// TuneInProfileContents is the modern JSON API that lists a
|
||||
// program's (`p<N>`) episodes. The legacy OPML endpoints can't —
|
||||
// `Tune.ashx?id=p<N>` returns `#STATUS: 400`, `Browse.ashx?id=p<N>`
|
||||
// only surfaces related genres + networks. Same payload is served
|
||||
// from api.tunein.com and api.radiotime.com; we use radiotime
|
||||
// because TuneInNavigateProfile already navigates there via
|
||||
// Pivots.Contents.Url, so all program-related traffic stays on the
|
||||
// same host that's already in allowedTuneInHosts. See
|
||||
// `_/i226/tunein-api-findings.md` for the full endpoint map.
|
||||
TuneInProfileContents = "https://api.radiotime.com/profiles/%s/contents?version=1.3"
|
||||
|
||||
// DefaultTuneInStreamFormats is the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
|
||||
// pre-2026-05-10 behaviour from before PR #249 added "hls"
|
||||
@@ -512,11 +523,26 @@ func tuneInSearchProfile(item map[string]interface{}, name string) models.BmxNav
|
||||
apiURL, _ := profile["Url"].(string)
|
||||
apiURLEncoded := base64.URLEncoding.EncodeToString([]byte(apiURL))
|
||||
|
||||
links := &models.Links{
|
||||
BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s/%s/%s", name, guideID, apiURLEncoded)},
|
||||
BmxPreset: &models.Link{ContainerArt: image, Href: fmt.Sprintf("/v1/preset/program/%s", guideID), Name: title, Type: "tracklisturl"},
|
||||
}
|
||||
|
||||
// Programs are containers, but with the `p` → `t` expansion in
|
||||
// TuneInPlaybackPodcast a single "play this program" click can now
|
||||
// route to the newest episode. Surface that as a BmxPlayback link so
|
||||
// the web UI renders a play button on the program card itself, not
|
||||
// just on individual episode cards reached by drilling in. Artists
|
||||
// stay navigate-only — there's no single sensible "play this artist"
|
||||
// stream.
|
||||
if name == "Program" && guideID != "" {
|
||||
encodedName := base64.URLEncoding.EncodeToString([]byte(title))
|
||||
playbackHref := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", guideID, encodedName)
|
||||
links.BmxPlayback = &models.Link{Href: playbackHref, Type: "tracklisturl"}
|
||||
}
|
||||
|
||||
return models.BmxNavItem{
|
||||
Links: &models.Links{
|
||||
BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s/%s/%s", name, guideID, apiURLEncoded)},
|
||||
BmxPreset: &models.Link{ContainerArt: image, Href: fmt.Sprintf("/v1/preset/program/%s", guideID), Name: title, Type: "tracklisturl"},
|
||||
},
|
||||
Links: links,
|
||||
ImageUrl: image,
|
||||
Name: title,
|
||||
Subtitle: subtitle,
|
||||
@@ -539,10 +565,26 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
profileTitle, _ := profileItem["Title"].(string)
|
||||
profileImage, _ := profileItem["Image"].(string)
|
||||
profileSubtitle, _ := profileItem["Subtitle"].(string)
|
||||
profileType, _ := profileItem["Type"].(string)
|
||||
profileGuideID, _ := profileItem["GuideId"].(string)
|
||||
|
||||
heroItem := models.BmxNavItem{Name: profileTitle, ImageUrl: profileImage, Subtitle: profileSubtitle}
|
||||
|
||||
// Surface "play latest episode" on the profile hero so users don't
|
||||
// have to scroll to the episode list. Matches the BmxPlayback link
|
||||
// emitted for Program cards in search results; the backend
|
||||
// p<N> → t<N> expansion resolves the actual stream.
|
||||
if profileType == "Program" && profileGuideID != "" {
|
||||
encodedName := base64.URLEncoding.EncodeToString([]byte(profileTitle))
|
||||
playbackHref := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", profileGuideID, encodedName)
|
||||
heroItem.Links = &models.Links{
|
||||
BmxPlayback: &models.Link{Href: playbackHref, Type: "tracklisturl"},
|
||||
}
|
||||
}
|
||||
|
||||
sections := []models.BmxNavSection{
|
||||
{
|
||||
Items: []models.BmxNavItem{{Name: profileTitle, ImageUrl: profileImage, Subtitle: profileSubtitle}},
|
||||
Items: []models.BmxNavItem{heroItem},
|
||||
Layout: "hero",
|
||||
Name: "",
|
||||
},
|
||||
@@ -578,6 +620,194 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseTuneInStreamBody filters a Tune.ashx response body down to the
|
||||
// playable stream URLs. TuneIn responds with HTTP 200 even on errors,
|
||||
// embedding a `#STATUS: <code>` comment line in the body (the body is
|
||||
// pls/m3u-like, so `#`-prefixed lines are comments — including error
|
||||
// markers like `#STATUS: 400`). Without this filter the caller would
|
||||
// happily pass `#STATUS: 400` to the speaker as if it were a stream URL.
|
||||
//
|
||||
// Returns the cleaned list of URL strings (TrimSpaced, comment lines
|
||||
// dropped, empty lines dropped). Returns an error if no playable URL
|
||||
// remains so callers surface a real 500 instead of silently corrupting
|
||||
// the playback response.
|
||||
func parseTuneInStreamBody(body []byte, guideID string) ([]string, error) {
|
||||
raw := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
out := make([]string, 0, len(raw))
|
||||
|
||||
for _, line := range raw {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, line)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("TuneIn returned no playable stream URL for guide-id %q (body: %q)",
|
||||
guideID, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// tuneInProfileContentsResponse models the subset of the
|
||||
// api.tunein.com/profiles/{id}/contents JSON we need to pick a
|
||||
// program's newest episode. The endpoint returns substantially more
|
||||
// fields per item; everything outside this struct is ignored.
|
||||
type tuneInProfileContentsResponse struct {
|
||||
Items []tuneInProfileContentsItem `json:"Items"`
|
||||
}
|
||||
|
||||
type tuneInProfileContentsItem struct {
|
||||
ContainerType string `json:"ContainerType"`
|
||||
Title string `json:"Title"`
|
||||
AccessibilityTitle string `json:"AccessibilityTitle"`
|
||||
Children []tuneInProfileContentsTopic `json:"Children"`
|
||||
}
|
||||
|
||||
type tuneInProfileContentsTopic struct {
|
||||
GuideId string `json:"GuideId"`
|
||||
Type string `json:"Type"`
|
||||
Title string `json:"Title"`
|
||||
Image string `json:"Image"`
|
||||
}
|
||||
|
||||
// parseTuneInProgramContents walks a profile/contents JSON body and
|
||||
// returns the guide-id of the newest playable episode. The contract:
|
||||
//
|
||||
// - Items[] entry with ContainerType=="Topics" and Title (or
|
||||
// AccessibilityTitle) equal to "Episodes" is treated as the
|
||||
// authoritative episode list.
|
||||
// - If no item matches by name, the first ContainerType=="Topics"
|
||||
// entry is used as fallback — TuneIn occasionally varies the
|
||||
// localised title.
|
||||
// - Inside the chosen container the first child with a `t`-prefixed
|
||||
// GuideId wins. TuneIn orders children newest-first.
|
||||
//
|
||||
// Returns a wrapped error if the body is malformed or contains no
|
||||
// playable topic; callers surface this as a 500 rather than handing
|
||||
// the speaker a broken stream URL.
|
||||
func parseTuneInProgramContents(body []byte, programID string) (episodeID string, err error) {
|
||||
var parsed tuneInProfileContentsResponse
|
||||
if decErr := json.Unmarshal(body, &parsed); decErr != nil {
|
||||
return "", fmt.Errorf("decode TuneIn profile/contents for %q: %w", programID, decErr)
|
||||
}
|
||||
|
||||
var fallback *tuneInProfileContentsItem
|
||||
|
||||
for i := range parsed.Items {
|
||||
item := &parsed.Items[i]
|
||||
if item.ContainerType != "Topics" {
|
||||
continue
|
||||
}
|
||||
|
||||
if fallback == nil {
|
||||
fallback = item
|
||||
}
|
||||
|
||||
if strings.EqualFold(item.Title, "Episodes") ||
|
||||
strings.EqualFold(item.AccessibilityTitle, "Episodes") {
|
||||
if id := firstTuneInTopicGuideID(item.Children); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fallback != nil {
|
||||
if id := firstTuneInTopicGuideID(fallback.Children); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no playable episode found in TuneIn profile/contents for program %q", programID)
|
||||
}
|
||||
|
||||
func firstTuneInTopicGuideID(children []tuneInProfileContentsTopic) string {
|
||||
for _, child := range children {
|
||||
if strings.HasPrefix(child.GuideId, "t") {
|
||||
return child.GuideId
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveTuneInProgramLatestEpisode fetches the program's profile from
|
||||
// api.tunein.com and returns the newest playable episode's topic
|
||||
// guide-id (the `t<N>` form Tune.ashx accepts). The legacy OPML
|
||||
// endpoints can't enumerate program episodes; see
|
||||
// `_/i226/tunein-api-findings.md` for the full endpoint contract.
|
||||
func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err error) {
|
||||
contentsURL := fmt.Sprintf(TuneInProfileContents, programID)
|
||||
|
||||
resp, err := http.Get(contentsURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TuneIn profile/contents returned status %d for program %q",
|
||||
resp.StatusCode, programID)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return parseTuneInProgramContents(body, programID)
|
||||
}
|
||||
|
||||
// TuneInDescribeMeta fetches just the display name and logo URL for a TuneIn
|
||||
// guide ID via the same describe endpoint TuneInPlayback uses. Useful for
|
||||
// CLI / UI enrichment that wants to populate ContentItem.ItemName +
|
||||
// ContainerArt before sending a SelectContentItem to the speaker — without
|
||||
// resolving the full stream URL.
|
||||
//
|
||||
// Returns empty strings (and a nil error) if the describe payload doesn't
|
||||
// contain a recognisable station / show element. Network errors and XML
|
||||
// decode errors surface verbatim.
|
||||
func TuneInDescribeMeta(id string) (name, logo string, err error) {
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, id)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Same shape TuneInPlayback parses for stations. For programs and
|
||||
// episodes the describe endpoint returns analogous structures; if
|
||||
// the station element is absent the response yields empty strings
|
||||
// and the caller can fall back to user-supplied values.
|
||||
var opml struct {
|
||||
Body struct {
|
||||
Outline struct {
|
||||
Station struct {
|
||||
Name string `xml:"name"`
|
||||
Logo string `xml:"logo"`
|
||||
} `xml:"station"`
|
||||
} `xml:"outline"`
|
||||
} `xml:"body"`
|
||||
}
|
||||
|
||||
if uErr := xml.Unmarshal(body, &opml); uErr != nil {
|
||||
return "", "", uErr
|
||||
}
|
||||
|
||||
return opml.Body.Outline.Station.Name, opml.Body.Outline.Station.Logo, nil
|
||||
}
|
||||
|
||||
// TuneInPlayback resolves a live radio station and returns a Bose-compatible
|
||||
// playback response with primary stream and variants. formats is the
|
||||
// comma-separated list passed to Tune.ashx?formats=… ; empty falls back to
|
||||
@@ -628,9 +858,9 @@ func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamURLList := strings.Split(strings.TrimSpace(string(streamBody)), "\n")
|
||||
if len(streamURLList) == 0 || streamURLList[0] == "" {
|
||||
return nil, fmt.Errorf("no streams found")
|
||||
streamURLList, err := parseTuneInStreamBody(streamBody, stationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamID := "e3342"
|
||||
@@ -725,7 +955,24 @@ func TuneInPodcastInfo(podcastID, encodedName string) (*models.BmxPodcastInfoRes
|
||||
// TuneInPlaybackPodcast resolves an on-demand podcast episode and returns
|
||||
// a playback response suitable for SoundTouch devices. formats has the
|
||||
// same semantics as in TuneInPlayback.
|
||||
//
|
||||
// Accepts three TuneIn guide-id shapes:
|
||||
// - `t<N>` — topic/episode; played directly.
|
||||
// - `e<N>` — live episode; played directly.
|
||||
// - `p<N>` — podcast program (a container, not a stream). Expanded
|
||||
// to its newest episode via the JSON profile/contents API before
|
||||
// resolving the stream URL. The legacy OPML `Tune.ashx?id=p<N>`
|
||||
// would return `#STATUS: 400` for this case.
|
||||
func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackResponse, error) {
|
||||
if strings.HasPrefix(podcastID, "p") {
|
||||
episodeID, resolveErr := resolveTuneInProgramLatestEpisode(podcastID)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
|
||||
podcastID = episodeID
|
||||
}
|
||||
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, podcastID)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
@@ -774,9 +1021,9 @@ func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackRespon
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamURLList := strings.Split(strings.TrimSpace(string(streamBody)), "\n")
|
||||
if len(streamURLList) == 0 || streamURLList[0] == "" {
|
||||
return nil, fmt.Errorf("no streams found")
|
||||
streamURLList, err := parseTuneInStreamBody(streamBody, podcastID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamID := "e3342"
|
||||
|
||||
@@ -270,3 +270,265 @@ func TestTuneInStream_OverrideHonoured(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTuneInStreamBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantURLs []string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "single URL",
|
||||
body: "https://stream.example.com/foo.mp3\n",
|
||||
wantURLs: []string{"https://stream.example.com/foo.mp3"},
|
||||
},
|
||||
{
|
||||
name: "multiple URLs",
|
||||
body: "https://a/1.mp3\nhttps://b/2.mp3\n",
|
||||
wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
|
||||
},
|
||||
{
|
||||
// The bug behind PR #313's i314 follow-up — TuneIn 200's the
|
||||
// response body with `#STATUS: 400` for guide-ids that aren't
|
||||
// streamable (e.g. podcast program IDs sent to Tune.ashx).
|
||||
// Pre-fix, this string went out to the speaker as if it were a
|
||||
// stream URL.
|
||||
name: "comment-only body — TuneIn 400 error",
|
||||
body: "#STATUS: 400\n#description=Bad request\n",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "comments mixed with real URL",
|
||||
body: "#EXTM3U\nhttps://stream.example.com/foo.mp3\n#END\n",
|
||||
wantURLs: []string{"https://stream.example.com/foo.mp3"},
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: "",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "only blank lines",
|
||||
body: "\n\n \n",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "trims surrounding whitespace per line",
|
||||
body: " https://a/1.mp3 \n\thttps://b/2.mp3\t\n",
|
||||
wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseTuneInStreamBody([]byte(tc.body), "test-guide-id")
|
||||
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "test-guide-id") {
|
||||
t.Errorf("error should mention the guide-id for diagnosis: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != len(tc.wantURLs) {
|
||||
t.Fatalf("len mismatch: got %d (%v), want %d (%v)", len(got), got, len(tc.wantURLs), tc.wantURLs)
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != tc.wantURLs[i] {
|
||||
t.Errorf("URL[%d] mismatch: got %q, want %q", i, got[i], tc.wantURLs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneInSearchProfileEmitsBmxPlayback pins the rule that
|
||||
// program-card play buttons appear in the web/CLI search UI: Program
|
||||
// search items get a BmxPlayback link (so the speaker hits our
|
||||
// podcast endpoint and the p<N> → t<N> expansion kicks in), while
|
||||
// Artist items stay navigate-only — there's no single sensible
|
||||
// stream for an artist.
|
||||
func TestTuneInSearchProfileEmitsBmxPlayback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
profileName string
|
||||
guideID string
|
||||
wantPlayback bool
|
||||
wantType string
|
||||
}{
|
||||
{name: "Program with guide-id gets play link", profileName: "Program", guideID: "p290778", wantPlayback: true, wantType: "tracklisturl"},
|
||||
{name: "Artist with guide-id is navigate-only", profileName: "Artist", guideID: "a12345", wantPlayback: false},
|
||||
{name: "Program without guide-id is navigate-only", profileName: "Program", guideID: "", wantPlayback: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
item := map[string]interface{}{
|
||||
"GuideId": tc.guideID,
|
||||
"Title": "Die Nachrichten",
|
||||
"Image": "http://example.com/logo.png",
|
||||
"Subtitle": "Deutschlandfunk",
|
||||
"Actions": map[string]interface{}{
|
||||
"Profile": map[string]interface{}{
|
||||
"Url": "https://api.radiotime.com/profiles/" + tc.guideID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
navItem := tuneInSearchProfile(item, tc.profileName)
|
||||
|
||||
if navItem.Links == nil {
|
||||
t.Fatal("expected Links to be set")
|
||||
}
|
||||
|
||||
if tc.wantPlayback {
|
||||
if navItem.Links.BmxPlayback == nil {
|
||||
t.Fatal("expected BmxPlayback link for Program")
|
||||
}
|
||||
|
||||
if navItem.Links.BmxPlayback.Type != tc.wantType {
|
||||
t.Errorf("BmxPlayback.Type = %q, want %q", navItem.Links.BmxPlayback.Type, tc.wantType)
|
||||
}
|
||||
|
||||
if !strings.Contains(navItem.Links.BmxPlayback.Href, tc.guideID) {
|
||||
t.Errorf("BmxPlayback.Href must carry the guide-id %q; got %q", tc.guideID, navItem.Links.BmxPlayback.Href)
|
||||
}
|
||||
|
||||
if !strings.Contains(navItem.Links.BmxPlayback.Href, "encoded_name=") {
|
||||
t.Errorf("BmxPlayback.Href should carry encoded_name; got %q", navItem.Links.BmxPlayback.Href)
|
||||
}
|
||||
} else if navItem.Links.BmxPlayback != nil {
|
||||
t.Errorf("did not expect BmxPlayback link; got %+v", navItem.Links.BmxPlayback)
|
||||
}
|
||||
|
||||
// Navigation drill-in must always remain available, even when
|
||||
// a play button is emitted — clicking the card body should
|
||||
// still take the user to the episode list.
|
||||
if navItem.Links.BmxNavigate == nil {
|
||||
t.Error("expected BmxNavigate link to remain available")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTuneInProgramContents pins the contract behind the
|
||||
// p<N> → t<N> expansion that powers `--program` playback for issue
|
||||
// #226. Real-world fixture shape captured from
|
||||
// api.tunein.com/profiles/p290778/contents (see
|
||||
// `_/i226/tunein-probe/profile_contents.json`).
|
||||
func TestParseTuneInProgramContents(t *testing.T) {
|
||||
const happyPath = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Episodes",
|
||||
"Children": [
|
||||
{ "GuideId": "t554138374", "Type": "Topic", "Title": "newest" },
|
||||
{ "GuideId": "t554134863", "Type": "Topic", "Title": "previous" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// TuneIn varies the localised container title; verify the
|
||||
// fallback picks the first Topics container even when the title
|
||||
// doesn't match "Episodes".
|
||||
const localisedTitle = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Folgen",
|
||||
"Children": [
|
||||
{ "GuideId": "t111", "Type": "Topic", "Title": "newest" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// "Episodes" container precedence: even if a "Related Shows"
|
||||
// Topics container appears first, we must pick the named one.
|
||||
const episodesAfterRelated = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Related Shows",
|
||||
"Children": [
|
||||
{ "GuideId": "t999", "Type": "Topic", "Title": "wrong" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Episodes",
|
||||
"Children": [
|
||||
{ "GuideId": "t222", "Type": "Topic", "Title": "right" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// Skip non-topic children — TuneIn occasionally mixes in
|
||||
// container-style children (rare, but defensive).
|
||||
const skipsNonTopic = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Episodes",
|
||||
"Children": [
|
||||
{ "GuideId": "p333", "Type": "Container", "Title": "nested program" },
|
||||
{ "GuideId": "t444", "Type": "Topic", "Title": "real episode" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantID string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "happy path — first child wins", body: happyPath, wantID: "t554138374"},
|
||||
{name: "localised title — falls back to first Topics container", body: localisedTitle, wantID: "t111"},
|
||||
{name: "Episodes container preferred over Related", body: episodesAfterRelated, wantID: "t222"},
|
||||
{name: "skips non-Topic children", body: skipsNonTopic, wantID: "t444"},
|
||||
{name: "empty body — error", body: `{}`, wantError: true},
|
||||
{name: "no Topics containers — error", body: `{"Items":[{"ContainerType":"Banner","Children":[]}]}`, wantError: true},
|
||||
{name: "Topics with no t-prefixed children — error",
|
||||
body: `{"Items":[{"ContainerType":"Topics","Title":"Episodes","Children":[{"GuideId":"p1"}]}]}`,
|
||||
wantError: true},
|
||||
{name: "malformed JSON — error", body: `{not json`, wantError: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseTuneInProgramContents([]byte(tc.body), "p290778")
|
||||
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got id=%q", got)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tc.wantID {
|
||||
t.Errorf("got episode id %q, want %q", got, tc.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,23 @@
|
||||
// Package handlers provides HTTP handlers for the SoundTouch service.
|
||||
// Package handlers — BMX registry / availability and shared helpers.
|
||||
//
|
||||
// Per-service handlers live in handlers_bmx_<service>.go:
|
||||
// - handlers_bmx_tunein.go (TuneIn — playback / podcasts / navigate / search / favorites / report)
|
||||
// - handlers_bmx_orion.go (Orion — LOCAL_INTERNET_RADIO token + station)
|
||||
// - handlers_bmx_custom.go (our own custom-playback adapter)
|
||||
//
|
||||
// The split happened on 2026-05-17 as a pure refactor — no logic change.
|
||||
// A future iteration may extract a common BMX-service interface (see
|
||||
// memory project_bmx_service_interface.md) once enough services are
|
||||
// fully implemented to make the common shape observable.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// tuneInStreamFormats returns the formats= list AfterTouch should send
|
||||
// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when
|
||||
// set. Empty (the default) lets bmx.TuneInStream fall back to
|
||||
// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible
|
||||
// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set
|
||||
// the field to "mp3,aac,ogg,hls" (or any other comma-separated list)
|
||||
// in settings.json.
|
||||
func (s *Server) tuneInStreamFormats() string {
|
||||
if s == nil || s.ds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
settings, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return settings.TuneInStreamFormats
|
||||
}
|
||||
|
||||
// HandleBMXRegistry returns the BMX service registry.
|
||||
func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) {
|
||||
baseURL := s.serverURL
|
||||
@@ -62,6 +45,67 @@ func (s *Server) HandleBMXServicesAvailability(w http.ResponseWriter, _ *http.Re
|
||||
_, _ = w.Write(bmxServicesAvailabilityJSON)
|
||||
}
|
||||
|
||||
// extractBMXService finds a single service entry in bmx_services.json by
|
||||
// its `id.name` (e.g. "SIRIUSXM_EVEREST", "TUNEIN"). Returns the raw JSON
|
||||
// segment for that service so callers can apply {BMX_SERVER} / {MEDIA_SERVER}
|
||||
// substitution and write it back to the wire.
|
||||
func extractBMXService(bmxJSON []byte, name string) (json.RawMessage, error) {
|
||||
var wrapper struct {
|
||||
BMXServices []json.RawMessage `json:"bmx_services"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bmxJSON, &wrapper); err != nil {
|
||||
return nil, fmt.Errorf("parse bmx_services.json: %w", err)
|
||||
}
|
||||
|
||||
for _, raw := range wrapper.BMXServices {
|
||||
var idOnly struct {
|
||||
ID struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, &idOnly); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if idOnly.ID.Name == name {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("service %q not found in bmx_services.json", name)
|
||||
}
|
||||
|
||||
// applyBMXTemplate runs the same {BMX_SERVER} / {MEDIA_SERVER} substitution
|
||||
// HandleBMXRegistry uses, so service-descriptor responses produced from
|
||||
// sub-segments of bmx_services.json land at the same hostnames the
|
||||
// registry advertises.
|
||||
func (s *Server) applyBMXTemplate(content string) string {
|
||||
baseURL := s.serverURL
|
||||
|
||||
s.mu.RLock()
|
||||
dnsEnabled := s.dnsEnabled
|
||||
s.mu.RUnlock()
|
||||
|
||||
bmxServer := baseURL
|
||||
if dnsEnabled {
|
||||
bmxServer = "https://content.api.bose.io"
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "{BMX_SERVER}", bmxServer)
|
||||
content = strings.ReplaceAll(content, "{MEDIA_SERVER}", baseURL+"/media")
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// writeBMXUnauthorized writes the canonical 401 used by every BMX adapter
|
||||
// handler that requires an Authorization header (TuneIn variants, Orion
|
||||
// playback). Currently unused because all gate sites are temporarily
|
||||
// disabled (log-only); kept as the future-restore point — when we re-add
|
||||
// the gate, callers will use this helper.
|
||||
//
|
||||
//nolint:unused // intentional: future-restore point for the disabled auth gate.
|
||||
func (s *Server) writeBMXUnauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
@@ -72,355 +116,3 @@ func (s *Server) writeBMXUnauthorized(w http.ResponseWriter) {
|
||||
<p>Authorization not set. No access token found.</p>
|
||||
`))
|
||||
}
|
||||
|
||||
// HandleTuneInPlayback returns TuneIn playback information.
|
||||
func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
|
||||
resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInPodcastInfo returns TuneIn podcast information.
|
||||
func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
encodedName := r.URL.Query().Get("encoded_name")
|
||||
|
||||
resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInPlaybackPodcast returns TuneIn podcast playback information.
|
||||
func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInToken returns a TuneIn access token.
|
||||
func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// For now, we return the provided refresh_token as access_token and refresh_token,
|
||||
// mirroring the behavior seen in the recordings.
|
||||
resp := map[string]string{
|
||||
"access_token": req.RefreshToken,
|
||||
"refresh_token": req.RefreshToken,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionToken returns an anonymous Orion access token.
|
||||
// The token is a base64-encoded JSON serial, matching the pattern used by the real Bose BMX Orion service.
|
||||
func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) {
|
||||
token := datastore.GenerateSerialSecret("orion")
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"_embedded": map[string]interface{}{
|
||||
"bmx_account": map[string]string{
|
||||
"displayName": "",
|
||||
"username": "",
|
||||
},
|
||||
},
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionPlayback returns Orion playback information for the
|
||||
// /core02/svc-bmx-adapter-orion/prod/orion/station?data=... endpoint
|
||||
// the speaker reaches by following its stored LOCAL_INTERNET_RADIO
|
||||
// preset's `location` attribute. The `data` query string is the
|
||||
// base64-encoded JSON blob (streamUrl/imageUrl/name) that the speaker
|
||||
// constructed when the preset was first saved; we just decode and
|
||||
// rewrap it into the Bose BmxPlaybackResponse shape via
|
||||
// bmx.PlayCustomStream.
|
||||
//
|
||||
// Requires a Bearer token in the `Authorization` header — same as
|
||||
// the rest of the BMX playback surface (TuneIn variants and the
|
||||
// orion token endpoint). Real speakers obtain the token via
|
||||
// POST /core02/svc-bmx-adapter-orion/prod/orion/token (HandleOrionToken)
|
||||
// before they ever follow a LOCAL_INTERNET_RADIO preset, so this
|
||||
// check shouldn't cost any legitimate caller.
|
||||
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
data := r.URL.Query().Get("data")
|
||||
|
||||
resp, err := bmx.PlayCustomStream(data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCustomPlayback returns custom playback information for a given stream URL.
|
||||
func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
encodedURL := chi.URLParam(r, "encodedURL")
|
||||
imageUrl := r.URL.Query().Get("imageUrl")
|
||||
name := r.URL.Query().Get("name")
|
||||
|
||||
// Decode URL if it's base64 encoded
|
||||
var streamUrl string
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(encodedURL)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(encodedURL)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
streamUrl = string(decoded)
|
||||
} else {
|
||||
// Try unescaping if it's not base64
|
||||
streamUrl, err = url.PathUnescape(encodedURL)
|
||||
if err != nil {
|
||||
streamUrl = encodedURL
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInReport handles TuneIn playback reporting.
|
||||
func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
EventType string `json:"eventType"`
|
||||
}
|
||||
|
||||
// We don't strictly need the body to determine the response,
|
||||
// but we decode it to see the eventType.
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if req.EventType == "START" {
|
||||
// Mirroring the response from 0196-20260329-233306.072-POST.http
|
||||
resp := map[string]interface{}{
|
||||
"_links": map[string]interface{}{
|
||||
"self": map[string]interface{}{
|
||||
"href": "/v1/report?" + r.URL.RawQuery,
|
||||
},
|
||||
},
|
||||
"nextReportIn": 1800,
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For STOP and other events, return an empty object
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate returns live TuneIn navigation results.
|
||||
// Path variants handled via chi wildcard:
|
||||
// - (empty) → top-level browse
|
||||
// - {encodedURI} → browse the given TuneIn URI
|
||||
// - sub/{n}/{encodedURI} → single subsection of a browse page
|
||||
// - profiles/{type}/{id}/{encodedURI} → artist/program profile page
|
||||
func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
resp, err := parseTuneInNavigatePath(wildcard)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTuneInNavigatePath(wildcard string) (interface{}, error) {
|
||||
if wildcard == "" {
|
||||
return bmx.TuneInNavigate("", nil)
|
||||
}
|
||||
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
prefix := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch prefix {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
return bmx.TuneInNavigate(rest, nil)
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(rest[:secondSlash])
|
||||
if err != nil {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
|
||||
case "profiles":
|
||||
// profiles/{type}/{id}/{encodedURI}
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigateProfile(parts[2])
|
||||
|
||||
default:
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch returns live TuneIn search results for the given query.
|
||||
func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmx.TuneInSearch(query)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.SaveTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to persist TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInDeleteFavorite handles DELETE /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.DeleteTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to delete TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package handlers — AfterTouch's own custom-playback adapter (not a
|
||||
// Bose-official BMX service). Reached via /custom/v1/playback/{encodedURL}
|
||||
// by speakers that follow our LOCAL_INTERNET_RADIO preset locations.
|
||||
//
|
||||
// Split out of handlers_bmx.go on 2026-05-17; pure file move, no logic
|
||||
// change.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleCustomPlayback returns custom playback information for a given stream URL.
|
||||
func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
encodedURL := chi.URLParam(r, "encodedURL")
|
||||
imageUrl := r.URL.Query().Get("imageUrl")
|
||||
name := r.URL.Query().Get("name")
|
||||
|
||||
// Decode URL if it's base64 encoded
|
||||
var streamUrl string
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(encodedURL)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(encodedURL)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
streamUrl = string(decoded)
|
||||
} else {
|
||||
// Try unescaping if it's not base64
|
||||
streamUrl, err = url.PathUnescape(encodedURL)
|
||||
if err != nil {
|
||||
streamUrl = encodedURL
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package handlers — Orion BMX adapter handlers (LOCAL_INTERNET_RADIO).
|
||||
//
|
||||
// Split out of handlers_bmx.go on 2026-05-17; pure file move, no logic
|
||||
// change. Shared helpers (writeBMXUnauthorized) still live in
|
||||
// handlers_bmx.go.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// HandleOrionToken returns an anonymous Orion access token.
|
||||
// The token is a base64-encoded JSON serial, matching the pattern used by the real Bose BMX Orion service.
|
||||
func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) {
|
||||
token := datastore.GenerateSerialSecret("orion")
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"_embedded": map[string]interface{}{
|
||||
"bmx_account": map[string]string{
|
||||
"displayName": "",
|
||||
"username": "",
|
||||
},
|
||||
},
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionPlayback returns Orion playback information for the
|
||||
// /core02/svc-bmx-adapter-orion/prod/orion/station?data=... endpoint
|
||||
// the speaker reaches by following its stored LOCAL_INTERNET_RADIO
|
||||
// preset's `location` attribute. The `data` query string is the
|
||||
// base64-encoded JSON blob (streamUrl/imageUrl/name) that the speaker
|
||||
// constructed when the preset was first saved; we just decode and
|
||||
// rewrap it into the Bose BmxPlaybackResponse shape via
|
||||
// bmx.PlayCustomStream.
|
||||
//
|
||||
// Requires a Bearer token in the `Authorization` header — same as
|
||||
// the rest of the BMX playback surface (TuneIn variants and the
|
||||
// orion token endpoint). Real speakers obtain the token via
|
||||
// POST /core02/svc-bmx-adapter-orion/prod/orion/token (HandleOrionToken)
|
||||
// before they ever follow a LOCAL_INTERNET_RADIO preset, so this
|
||||
// check shouldn't cost any legitimate caller.
|
||||
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// See HandleTuneInPlayback for the rationale. Logged so we can spot
|
||||
// callers that would have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
data := r.URL.Query().Get("data")
|
||||
|
||||
resp, err := bmx.PlayCustomStream(data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ func TestHandleTuneInReport(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Unauthorized", func(t *testing.T) {
|
||||
t.Skip("auth gate temporarily disabled in handlers_bmx_tunein.go; restore this assertion when the gate is re-enabled")
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/bmx/tunein/v1/report", nil)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package handlers — SiriusXM BMX adapter (logging stub).
|
||||
//
|
||||
// bmx_services.json declares SIRIUSXM_EVEREST at
|
||||
// `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`,
|
||||
// and bmx_services_availability.json lists it as available — so speakers
|
||||
// that try SiriusXM hit this path. The bare URL returns the service
|
||||
// descriptor; sub-paths advertised by the descriptor's _links
|
||||
// (/availability, /token, /navigate, /logout, plus the playback paths
|
||||
// the speaker discovers via navigate) currently log + 404 so we have
|
||||
// visibility into real speaker calls for the next implementation pass.
|
||||
//
|
||||
// Reference: deborahgu/soundcork main.py:805 takes the same shape —
|
||||
// returns the SiriusXM service descriptor from the BMX services array
|
||||
// (hardcoded index 2). We select by id.name instead of array index.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleSiriusXMLiveAdapter returns the SIRIUSXM_EVEREST service descriptor
|
||||
// from bmx_services.json for the bare live-adapter base URL.
|
||||
//
|
||||
// NB: we log the *presence* of the Authorization header, not its value —
|
||||
// the header carries a long-lived bearer token (margeAuthToken) that
|
||||
// would be replayable if a logfile got captured. CodeQL
|
||||
// go/clear-text-logging caught the original `auth=%q` shape.
|
||||
func (s *Server) HandleSiriusXMLiveAdapter(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[BMX SiriusXM] %s %s ua=%q authPresent=%t query=%q",
|
||||
r.Method, r.URL.Path, r.UserAgent(),
|
||||
r.Header.Get("Authorization") != "", r.URL.RawQuery)
|
||||
|
||||
svc, err := extractBMXService(bmxServicesJSON, "SIRIUSXM_EVEREST")
|
||||
if err != nil {
|
||||
log.Printf("[BMX SiriusXM] failed to extract service descriptor: %v", err)
|
||||
http.Error(w, "service descriptor unavailable", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body := s.applyBMXTemplate(string(svc))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
// HandleSiriusXMLiveAdapterSubpath logs and 404s any unimplemented sub-path
|
||||
// under the SiriusXM live-adapter. Visibility for the next implementation
|
||||
// pass — the _links in the descriptor publish /availability, /token,
|
||||
// /navigate, /logout; playback URLs come dynamically from navigate.
|
||||
func (s *Server) HandleSiriusXMLiveAdapterSubpath(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[BMX SiriusXM] UNIMPLEMENTED %s %s ua=%q authPresent=%t query=%q",
|
||||
r.Method, r.URL.Path, r.UserAgent(),
|
||||
r.Header.Get("Authorization") != "", r.URL.RawQuery)
|
||||
|
||||
http.Error(w, "not implemented", http.StatusNotFound)
|
||||
}
|
||||
@@ -159,6 +159,8 @@ func TestCustomPlayback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBMXUnauthorized(t *testing.T) {
|
||||
t.Skip("auth gate temporarily disabled in handlers_bmx_tunein.go and handlers_bmx_orion.go; restore this assertion when the gate is re-enabled")
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// Package handlers — TuneIn BMX adapter handlers.
|
||||
//
|
||||
// Split out of handlers_bmx.go on 2026-05-17; pure file move, no logic
|
||||
// change. Shared helpers (writeBMXUnauthorized, bmxServicesJSON) still
|
||||
// live in handlers_bmx.go.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// tuneInStreamFormats returns the formats= list AfterTouch should send
|
||||
// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when
|
||||
// set. Empty (the default) lets bmx.TuneInStream fall back to
|
||||
// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible
|
||||
// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set
|
||||
// the field to "mp3,aac,ogg,hls" (or any other comma-separated list)
|
||||
// in settings.json.
|
||||
func (s *Server) tuneInStreamFormats() string {
|
||||
if s == nil || s.ds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
settings, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return settings.TuneInStreamFormats
|
||||
}
|
||||
|
||||
// HandleTuneInPlayback returns TuneIn playback information.
|
||||
func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
|
||||
resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInPodcastInfo returns TuneIn podcast information.
|
||||
func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
encodedName := r.URL.Query().Get("encoded_name")
|
||||
|
||||
resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInPlaybackPodcast returns TuneIn podcast playback information.
|
||||
func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInToken returns a TuneIn access token.
|
||||
func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// For now, we return the provided refresh_token as access_token and refresh_token,
|
||||
// mirroring the behavior seen in the recordings.
|
||||
resp := map[string]string{
|
||||
"access_token": req.RefreshToken,
|
||||
"refresh_token": req.RefreshToken,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInReport handles TuneIn playback reporting.
|
||||
func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
EventType string `json:"eventType"`
|
||||
}
|
||||
|
||||
// We don't strictly need the body to determine the response,
|
||||
// but we decode it to see the eventType.
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if req.EventType == "START" {
|
||||
// Mirroring the response from 0196-20260329-233306.072-POST.http
|
||||
resp := map[string]interface{}{
|
||||
"_links": map[string]interface{}{
|
||||
"self": map[string]interface{}{
|
||||
"href": "/v1/report?" + r.URL.RawQuery,
|
||||
},
|
||||
},
|
||||
"nextReportIn": 1800,
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For STOP and other events, return an empty object
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate returns live TuneIn navigation results.
|
||||
// Path variants handled via chi wildcard:
|
||||
// - (empty) → top-level browse
|
||||
// - {encodedURI} → browse the given TuneIn URI
|
||||
// - sub/{n}/{encodedURI} → single subsection of a browse page
|
||||
// - profiles/{type}/{id}/{encodedURI} → artist/program profile page
|
||||
func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
resp, err := parseTuneInNavigatePath(wildcard)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTuneInNavigatePath(wildcard string) (interface{}, error) {
|
||||
if wildcard == "" {
|
||||
return bmx.TuneInNavigate("", nil)
|
||||
}
|
||||
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
prefix := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch prefix {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
return bmx.TuneInNavigate(rest, nil)
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(rest[:secondSlash])
|
||||
if err != nil {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
|
||||
case "profiles":
|
||||
// profiles/{type}/{id}/{encodedURI}
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigateProfile(parts[2])
|
||||
|
||||
default:
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch returns live TuneIn search results for the given query.
|
||||
func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmx.TuneInSearch(query)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.SaveTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to persist TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInDeleteFavorite handles DELETE /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.DeleteTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to delete TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
@@ -47,6 +47,8 @@ func TestHandleTuneInNavigate(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Unauthorized", func(t *testing.T) {
|
||||
t.Skip("auth gate temporarily disabled in handlers_bmx_tunein.go; restore this assertion when the gate is re-enabled")
|
||||
|
||||
req := httptest.NewRequest("GET", "/bmx/tunein/v1/navigate", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -83,6 +85,8 @@ func TestHandleTuneInSearch(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Unauthorized", func(t *testing.T) {
|
||||
t.Skip("auth gate temporarily disabled in handlers_bmx_tunein.go; restore this assertion when the gate is re-enabled")
|
||||
|
||||
req := httptest.NewRequest("GET", "/bmx/tunein/v1/search?q=music", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// Package stockholm implements the Stockholm frontend backend: native bridge,
|
||||
// HTTP proxy, static file serving, SSDP discovery, and state persistence.
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bridgeMessage is a single message in the runQueue response.
|
||||
type bridgeMessage struct {
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error interface{} `json:"error,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
ID interface{} `json:"id"`
|
||||
}
|
||||
|
||||
// appSendRequest is the JSON body of a POST /api/native/appSend call.
|
||||
type appSendRequest struct {
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
ID interface{} `json:"id"`
|
||||
}
|
||||
|
||||
// Bridge manages per-clientId message queues for the native bridge.
|
||||
type Bridge struct {
|
||||
cfg *Config
|
||||
state *NativeState
|
||||
queues sync.Map // clientId -> *clientQueue
|
||||
}
|
||||
|
||||
type clientQueue struct {
|
||||
mu sync.Mutex
|
||||
msgs []bridgeMessage
|
||||
}
|
||||
|
||||
func newBridge(cfg *Config, state *NativeState) *Bridge {
|
||||
return &Bridge{cfg: cfg, state: state}
|
||||
}
|
||||
|
||||
// HandleAppSend serves POST /api/native/appSend.
|
||||
func (b *Bridge) HandleAppSend(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := resolveClientID(r)
|
||||
|
||||
var req appSendRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
b.enqueueError(clientID, nil, "invalid_request")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
b.dispatch(clientID, req)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleRunQueue serves GET /api/native/runQueue.
|
||||
func (b *Bridge) HandleRunQueue(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := resolveClientID(r)
|
||||
q := b.getOrCreateQueue(clientID)
|
||||
|
||||
q.mu.Lock()
|
||||
msgs := q.msgs
|
||||
q.msgs = nil
|
||||
q.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
type runQueueResponse struct {
|
||||
Messages []bridgeMessage `json:"messages"`
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(runQueueResponse{Messages: msgs}); err != nil {
|
||||
log.Printf("[Stockholm bridge] Failed to encode runQueue response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) dispatch(clientID string, req appSendRequest) {
|
||||
method := req.Method
|
||||
params := req.Params
|
||||
|
||||
if params == nil {
|
||||
params = map[string]interface{}{}
|
||||
}
|
||||
|
||||
id := req.ID
|
||||
|
||||
log.Printf("[Stockholm bridge] method=%q client=%q", method, clientID)
|
||||
|
||||
switch method {
|
||||
case "locale", "htmlReady", "stopHrmsUpdates":
|
||||
// no-op
|
||||
|
||||
case "log":
|
||||
if msg, _ := params["msg"].(string); msg != "" {
|
||||
log.Printf("[Stockholm:%s] %s", clientID, msg)
|
||||
}
|
||||
|
||||
case "setData":
|
||||
name, _ := params["name"].(string)
|
||||
if name != "" {
|
||||
value := stringifyScalar(params["value"])
|
||||
b.state.Set(name, value)
|
||||
}
|
||||
|
||||
case "getData":
|
||||
name, _ := params["name"].(string)
|
||||
b.enqueueResult(clientID, id, b.state.Get(name), "")
|
||||
|
||||
case "getLanStatus":
|
||||
b.enqueueResult(clientID, id, true, nil)
|
||||
|
||||
case "getTimeZone":
|
||||
b.enqueueResult(clientID, id, map[string]interface{}{
|
||||
"timezoneInfo": localTimezoneName(),
|
||||
"timeFormat": "TIME_FORMAT_24HOUR_ID",
|
||||
}, "")
|
||||
|
||||
case "getLegalDocPath":
|
||||
b.enqueueResult(clientID, id, legalDocPath(params), nil)
|
||||
|
||||
case "getConstant":
|
||||
name, _ := params["name"].(string)
|
||||
val := b.state.Get("constant." + name)
|
||||
|
||||
if val == "" && name == "kilo" {
|
||||
val = kiloDefaultValue
|
||||
}
|
||||
|
||||
b.enqueueResult(clientID, id, val, "")
|
||||
|
||||
case "canPerformAutoAPSetup":
|
||||
b.enqueueResult(clientID, id, map[string]interface{}{
|
||||
"permission": false,
|
||||
"location": false,
|
||||
}, "")
|
||||
|
||||
case "getDeviceList":
|
||||
go b.runDeviceDiscovery(clientID, id)
|
||||
|
||||
case "getHrmsList":
|
||||
go b.runServerDiscovery(clientID, id)
|
||||
|
||||
case "getNetStats", "getSSIDList", "setSSID", "updateSetting", "oauth",
|
||||
"downloadNewGui", "installNewGui", "sendLogs",
|
||||
"socketCreate", "socketSend", "socketClose":
|
||||
b.enqueueError(clientID, id, "unsupported")
|
||||
|
||||
default:
|
||||
b.enqueueError(clientID, id, "unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) runDeviceDiscovery(clientID string, _ interface{}) {
|
||||
expectedAccount := b.state.Get("margeAccountID")
|
||||
|
||||
// The JS "devices" handler reconciles the full list: it removes any device
|
||||
// not present in the latest message. Sending one device at a time would
|
||||
// therefore drop the previous device on each update. Always send the
|
||||
// cumulative list so existing entries are preserved.
|
||||
var seen []RendererDevice
|
||||
|
||||
devices := DiscoverRenderers(expectedAccount, func(d RendererDevice) {
|
||||
seen = append(seen, d)
|
||||
b.enqueueMethod(clientID, "devices", seen)
|
||||
})
|
||||
|
||||
if len(devices) == 0 {
|
||||
b.enqueueMethod(clientID, "devices", []RendererDevice{})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) runServerDiscovery(clientID string, _ interface{}) {
|
||||
servers := DiscoverServers()
|
||||
b.enqueueMethod(clientID, "servers", servers)
|
||||
}
|
||||
|
||||
func (b *Bridge) enqueueResult(clientID string, id, result, errVal interface{}) {
|
||||
b.enqueue(clientID, bridgeMessage{Result: result, Error: errVal, ID: id})
|
||||
}
|
||||
|
||||
func (b *Bridge) enqueueError(clientID string, id interface{}, errMsg string) {
|
||||
b.enqueue(clientID, bridgeMessage{Result: nil, Error: errMsg, ID: id})
|
||||
}
|
||||
|
||||
func (b *Bridge) enqueueMethod(clientID, method string, params interface{}) {
|
||||
b.enqueue(clientID, bridgeMessage{Method: method, Params: params, ID: nil})
|
||||
}
|
||||
|
||||
func (b *Bridge) enqueue(clientID string, msg bridgeMessage) {
|
||||
q := b.getOrCreateQueue(clientID)
|
||||
|
||||
q.mu.Lock()
|
||||
q.msgs = append(q.msgs, msg)
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bridge) getOrCreateQueue(clientID string) *clientQueue {
|
||||
v, _ := b.queues.LoadOrStore(clientID, &clientQueue{})
|
||||
q, _ := v.(*clientQueue)
|
||||
|
||||
if q == nil {
|
||||
q = &clientQueue{}
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func resolveClientID(r *http.Request) string {
|
||||
if v := r.Header.Get("X-Stockholm-Client-Id"); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("clientId"); v != "" {
|
||||
decoded, err := url.QueryUnescape(v)
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
return "default"
|
||||
}
|
||||
|
||||
func legalDocPath(params map[string]interface{}) string {
|
||||
typVal, _ := params["type"].(string)
|
||||
lang, _ := params["lang"].(string)
|
||||
|
||||
// "lcns" = third-party platform/GUI licences; the Stockholm zip ships this
|
||||
// as gui_licenses_en.txt (no per-language variants exist).
|
||||
if typVal == "lcns" {
|
||||
return "legal/gui_licenses_en.txt"
|
||||
}
|
||||
|
||||
if typVal == "" || typVal == "eula" {
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
|
||||
return "legal/eula_" + lang + ".txt"
|
||||
}
|
||||
|
||||
// "privacy" and any other types: the Stockholm zip does not include these
|
||||
// files, so fall back to the English EULA.
|
||||
return "legal/eula_en.txt"
|
||||
}
|
||||
|
||||
func localTimezoneName() string {
|
||||
return time.Local.String()
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- resolveClientID ----
|
||||
|
||||
func TestResolveClientID_Header(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Stockholm-Client-Id", "tab-123")
|
||||
|
||||
if got := resolveClientID(r); got != "tab-123" {
|
||||
t.Errorf("expected tab-123, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_QueryParam(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/?clientId=browser-abc", nil)
|
||||
|
||||
if got := resolveClientID(r); got != "browser-abc" {
|
||||
t.Errorf("expected browser-abc, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_Default(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
if got := resolveClientID(r); got != "default" {
|
||||
t.Errorf("expected default, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_HeaderTakesPrecedence(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/?clientId=from-query", nil)
|
||||
r.Header.Set("X-Stockholm-Client-Id", "from-header")
|
||||
|
||||
if got := resolveClientID(r); got != "from-header" {
|
||||
t.Errorf("expected from-header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- legalDocPath ----
|
||||
|
||||
func TestLegalDocPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
params map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{map[string]interface{}{"type": "lcns"}, "legal/gui_licenses_en.txt"},
|
||||
{map[string]interface{}{}, "legal/eula_en.txt"},
|
||||
{map[string]interface{}{"type": "eula", "lang": "de"}, "legal/eula_de.txt"},
|
||||
{map[string]interface{}{"type": "privacy"}, "legal/eula_en.txt"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := legalDocPath(tc.params); got != tc.want {
|
||||
t.Errorf("legalDocPath(%v) = %q, want %q", tc.params, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Bridge dispatch via HTTP handlers ----
|
||||
|
||||
func newTestBridge(t *testing.T) *Bridge {
|
||||
t.Helper()
|
||||
state := NewNativeState(t.TempDir())
|
||||
return newBridge(&Config{}, state)
|
||||
}
|
||||
|
||||
func appSend(t *testing.T, b *Bridge, method string, params map[string]interface{}, id interface{}) {
|
||||
t.Helper()
|
||||
body := map[string]interface{}{"method": method, "params": params, "id": id}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal appSend body: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/native/appSend", bytes.NewReader(data))
|
||||
req.Header.Set("X-Stockholm-Client-Id", "test")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.HandleAppSend(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("HandleAppSend returned %d, want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func drainQueue(t *testing.T, b *Bridge) []bridgeMessage {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/native/runQueue", nil)
|
||||
req.Header.Set("X-Stockholm-Client-Id", "test")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.HandleRunQueue(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("HandleRunQueue returned %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []bridgeMessage `json:"messages"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode runQueue response: %v", err)
|
||||
}
|
||||
|
||||
return resp.Messages
|
||||
}
|
||||
|
||||
// TestBridge_DeviceDiscovery_CumulativeList verifies that each "devices" message
|
||||
// sent during incremental discovery includes all previously found devices.
|
||||
// The JS "devices" handler reconciles the full list and would drop earlier
|
||||
// devices if only the latest one were included.
|
||||
func TestBridge_DeviceDiscovery_CumulativeList(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
|
||||
// Simulate what runDeviceDiscovery now does: build a cumulative slice and
|
||||
// enqueue it with every new device.
|
||||
d1 := RendererDevice{UID: "AABBCC112233", IP: "192.168.1.10"}
|
||||
d2 := RendererDevice{UID: "DDEEFF445566", IP: "192.168.1.11"}
|
||||
|
||||
var seen []RendererDevice
|
||||
for _, d := range []RendererDevice{d1, d2} {
|
||||
seen = append(seen, d)
|
||||
b.enqueueMethod("test", "devices", seen)
|
||||
}
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 queued messages, got %d", len(msgs))
|
||||
}
|
||||
|
||||
// First message: only d1
|
||||
firstParams, ok := msgs[0].Params.([]interface{})
|
||||
if !ok || len(firstParams) != 1 {
|
||||
t.Errorf("first message: expected 1-element params, got %v", msgs[0].Params)
|
||||
}
|
||||
|
||||
// Second message: d1 AND d2
|
||||
secondParams, ok := msgs[1].Params.([]interface{})
|
||||
if !ok || len(secondParams) != 2 {
|
||||
t.Errorf("second message: expected 2-element params, got %v", msgs[1].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_Locale_IsNoOp(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "locale", nil, 1)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 0 {
|
||||
t.Errorf("expected no messages for locale, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_GetLanStatus_ReturnsTrue(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "getLanStatus", nil, 42)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
if msgs[0].Result != true {
|
||||
t.Errorf("expected result=true, got %v", msgs[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_SetData_Get_RoundTrip(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "setData", map[string]interface{}{"name": "myKey", "value": "hello"}, nil)
|
||||
appSend(t, b, "getData", map[string]interface{}{"name": "myKey"}, 7)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message (for getData), got %d", len(msgs))
|
||||
}
|
||||
|
||||
if msgs[0].Result != "hello" {
|
||||
t.Errorf("expected result=hello, got %v", msgs[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_GetConstant_Kilo_Default(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "getConstant", map[string]interface{}{"name": "kilo"}, 1)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
if msgs[0].Result != "a7928d7b43dcd49f0af31e5aeed26458" {
|
||||
t.Errorf("unexpected kilo value: %v", msgs[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_GetTimeZone_ContainsTimezoneInfo(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "getTimeZone", nil, 2)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
result, ok := msgs[0].Result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map result, got %T", msgs[0].Result)
|
||||
}
|
||||
|
||||
if _, hasKey := result["timezoneInfo"]; !hasKey {
|
||||
t.Error("expected timezoneInfo key in getTimeZone result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_CanPerformAutoAPSetup_ReturnsFalse(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "canPerformAutoAPSetup", nil, 3)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
result, ok := msgs[0].Result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map result, got %T", msgs[0].Result)
|
||||
}
|
||||
|
||||
if result["permission"] != false {
|
||||
t.Errorf("expected permission=false, got %v", result["permission"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_UnsupportedMethod_ReturnsError(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "downloadNewGui", nil, 99)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
if msgs[0].Error == nil {
|
||||
t.Error("expected error for unsupported method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_Log_IsNoOp(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "log", map[string]interface{}{"msg": "hello from js"}, nil)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 0 {
|
||||
t.Errorf("expected no queued messages for log, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_GetLegalDocPath(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "getLegalDocPath", map[string]interface{}{"type": "lcns"}, 5)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(msgs[0].Result.(string), "gui_licenses_en.txt") {
|
||||
t.Errorf("unexpected legal doc path: %v", msgs[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_RunQueue_EmptyWhenNoPendingMessages(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
msgs := drainQueue(t, b)
|
||||
|
||||
if len(msgs) != 0 {
|
||||
t.Errorf("expected empty queue, got %d messages", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_AppSend_WrongMethod_Returns405(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/native/appSend", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.HandleAppSend(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_RunQueue_WrongMethod_Returns405(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/native/runQueue", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.HandleRunQueue(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds the parsed stockholm/json/config.json values.
|
||||
type Config struct {
|
||||
AppVersion string
|
||||
ProtocolVersion string
|
||||
StreamingVersion string
|
||||
CustomerVersion string
|
||||
DefaultMargeURL string
|
||||
DefaultUpdateURL string
|
||||
BmxRegistryURL string
|
||||
AuthServiceURL string
|
||||
EncryptedBmxToken string
|
||||
MargeServerKey string
|
||||
MargeServerKeyHeader string
|
||||
// BasePath is an optional URL prefix under which the Stockholm frontend is
|
||||
// served (e.g. "/stockholm"). Empty means served at "/".
|
||||
BasePath string
|
||||
}
|
||||
|
||||
// BackendConfig holds the parsed backend/config/backend-config.json values.
|
||||
type BackendConfig struct {
|
||||
FrontendLoggingLevel int `json:"frontendLoggingLevel"`
|
||||
}
|
||||
|
||||
var versionPrefix = regexp.MustCompile(`^(\d+(?:\.\d+)+)`)
|
||||
|
||||
// LoadConfig reads and parses stockholm/json/config.json from stockholmDir.
|
||||
func LoadConfig(stockholmDir string) (*Config, error) {
|
||||
path := filepath.Join(stockholmDir, "json", "config.json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config.json: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse config.json: %w", err)
|
||||
}
|
||||
|
||||
appVersions := jsonObject(raw["app_versions"])
|
||||
apiVersions := jsonObject(raw["api_versions"])
|
||||
defaults := jsonObject(raw["default"])
|
||||
|
||||
cfg := &Config{
|
||||
AppVersion: jsonString(appVersions["bose_app"]),
|
||||
ProtocolVersion: jsonString(appVersions["bose_protocol"]),
|
||||
StreamingVersion: firstNonEmpty(jsonString(apiVersions["bose_streaming"]), "1.0"),
|
||||
CustomerVersion: firstNonEmpty(jsonString(apiVersions["bose_customer"]), "1.0"),
|
||||
DefaultMargeURL: normalizeBaseURL(decodeB64(jsonString(defaults["d0"]))),
|
||||
DefaultUpdateURL: normalizeBaseURL(decodeB64(jsonString(defaults["d1"]))),
|
||||
BmxRegistryURL: decodeB64(jsonString(defaults["d3"])),
|
||||
AuthServiceURL: decodeB64(jsonString(defaults["d6"])),
|
||||
EncryptedBmxToken: decodeB64(jsonString(defaults["d7"])),
|
||||
MargeServerKey: decodeB64(jsonString(defaults["d10"])),
|
||||
MargeServerKeyHeader: decodeB64(jsonString(defaults["d13"])),
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RewriteConfigURLs updates the base64-encoded URL fields in stockholm/json/config.json
|
||||
// to point at backendURL. margeURL is used for streaming.bose.com rewrites; if empty it
|
||||
// defaults to backendURL. Set margeURL to backendURL+"/marge" when using soundcork.
|
||||
// authServiceURL is written into d6 (the auth endpoint); if empty it defaults to
|
||||
// backendURL. A trailing slash is always ensured because the JS concatenates paths like
|
||||
// "oauth/account/..." directly onto this value.
|
||||
func RewriteConfigURLs(stockholmDir, backendURL, margeURL, authServiceURL string) error {
|
||||
if margeURL == "" {
|
||||
margeURL = backendURL
|
||||
}
|
||||
|
||||
if authServiceURL == "" {
|
||||
authServiceURL = backendURL
|
||||
}
|
||||
|
||||
// Ensure trailing slash so JS path concatenation (e.g. d6 + "oauth/account/...")
|
||||
// produces a valid URL.
|
||||
if authServiceURL != "" && !strings.HasSuffix(authServiceURL, "/") {
|
||||
authServiceURL += "/"
|
||||
}
|
||||
|
||||
path := filepath.Join(stockholmDir, "json", "config.json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config.json: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
|
||||
if unmarshalErr := json.Unmarshal(data, &raw); unmarshalErr != nil {
|
||||
return fmt.Errorf("parse config.json: %w", unmarshalErr)
|
||||
}
|
||||
|
||||
defaults := jsonObject(raw["default"])
|
||||
|
||||
// Decode each field, substitute known hostnames, re-encode
|
||||
replacements := map[string]string{
|
||||
"https://streaming.bose.com": margeURL,
|
||||
"https://events.api.bosecm.com": backendURL,
|
||||
"https://content.api.bose.io": backendURL,
|
||||
"https://worldwide.bose.com": backendURL,
|
||||
"https://downloads.bose.com": backendURL,
|
||||
}
|
||||
|
||||
for key, rawVal := range defaults {
|
||||
decoded := decodeB64(jsonString(rawVal))
|
||||
|
||||
for old, newVal := range replacements {
|
||||
decoded = strings.ReplaceAll(decoded, old, newVal)
|
||||
}
|
||||
|
||||
defaults[key] = jsonRawString(base64.StdEncoding.EncodeToString([]byte(decoded)))
|
||||
}
|
||||
|
||||
// d6 = auth service base URL; always overwrite with a full URL so the JS
|
||||
// does not fall back to treating it as a subdomain prefix.
|
||||
defaults["d6"] = jsonRawString(base64.StdEncoding.EncodeToString([]byte(authServiceURL)))
|
||||
|
||||
// Re-serialize defaults back into the raw map
|
||||
encoded, err := json.Marshal(defaults)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal defaults: %w", err)
|
||||
}
|
||||
|
||||
raw["default"] = json.RawMessage(encoded)
|
||||
|
||||
out, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config.json: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(path, append(out, '\n'), 0644)
|
||||
}
|
||||
|
||||
// LoadBackendConfig reads backend/config/backend-config.json from workspaceRoot.
|
||||
// Returns defaults if the file is absent.
|
||||
func LoadBackendConfig(workspaceRoot string) *BackendConfig {
|
||||
path := filepath.Join(workspaceRoot, "backend", "config", "backend-config.json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return &BackendConfig{}
|
||||
}
|
||||
|
||||
var cfg BackendConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return &BackendConfig{}
|
||||
}
|
||||
|
||||
if cfg.FrontendLoggingLevel < 0 {
|
||||
cfg.FrontendLoggingLevel = 0
|
||||
}
|
||||
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// StreamingMediaType returns the Accept/Content-Type for streaming API calls.
|
||||
func (c *Config) StreamingMediaType() string {
|
||||
return "application/vnd.bose.streaming-v" + c.StreamingVersion + "+xml"
|
||||
}
|
||||
|
||||
// CustomerMediaType returns the Accept/Content-Type for customer API calls.
|
||||
func (c *Config) CustomerMediaType() string {
|
||||
return "application/vnd.bose.customer-v" + c.CustomerVersion + "+xml"
|
||||
}
|
||||
|
||||
// MediaTypeForPath returns the appropriate media type based on path.
|
||||
func (c *Config) MediaTypeForPath(path string) string {
|
||||
p := strings.ToLower(path)
|
||||
if strings.Contains(p, "/customer/") {
|
||||
return c.CustomerMediaType()
|
||||
}
|
||||
|
||||
if strings.Contains(p, "/streaming/") {
|
||||
return c.StreamingMediaType()
|
||||
}
|
||||
|
||||
return "application/xml"
|
||||
}
|
||||
|
||||
// IsBmxTarget returns true if host is a BMX API target.
|
||||
func (c *Config) IsBmxTarget(host string) bool {
|
||||
h := strings.ToLower(host)
|
||||
|
||||
return h == "content.api.bose.io" ||
|
||||
h == "test.content.api.bose.io" ||
|
||||
h == "bose-prod.apigee.net" ||
|
||||
strings.HasSuffix(h, ".apigee.net")
|
||||
}
|
||||
|
||||
// IsMargeTarget returns true if host+path is a Marge streaming/customer endpoint.
|
||||
func (c *Config) IsMargeTarget(host, path string) bool {
|
||||
h := strings.ToLower(host)
|
||||
|
||||
p := strings.ToLower(path)
|
||||
if !strings.Contains(p, "/streaming/") && !strings.Contains(p, "/customer/") {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasSuffix(h, ".bose.com") || strings.HasSuffix(h, ".apigee.net")
|
||||
}
|
||||
|
||||
// ExtractVersionPrefix returns the leading version number (e.g. "27.0" from "27.0.13-xyz").
|
||||
func ExtractVersionPrefix(v string) string {
|
||||
m := versionPrefix.FindStringSubmatch(v)
|
||||
if len(m) >= 2 {
|
||||
return m[1]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ShouldEnableFrontendDebug returns true when logging level > 0.
|
||||
func (b *BackendConfig) ShouldEnableFrontendDebug() bool {
|
||||
return b.FrontendLoggingLevel > 0
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func decodeB64(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
// Some values may not be base64 (already plain), return as-is
|
||||
return s
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func normalizeBaseURL(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(s, "/") {
|
||||
return s + "/"
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func jsonObject(raw json.RawMessage) map[string]json.RawMessage {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var obj map[string]json.RawMessage
|
||||
|
||||
_ = json.Unmarshal(raw, &obj)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func jsonString(raw json.RawMessage) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func jsonRawString(s string) json.RawMessage {
|
||||
// json.Marshal on a string never fails
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return json.RawMessage(`""`)
|
||||
}
|
||||
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- ExtractVersionPrefix ----
|
||||
|
||||
func TestExtractVersionPrefix(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"27.0.13-release", "27.0.13"},
|
||||
{"27.0", "27.0"},
|
||||
{"1.2.3.4", "1.2.3.4"},
|
||||
{"v27.0", ""},
|
||||
{"", ""},
|
||||
{"release-27.0", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := ExtractVersionPrefix(tc.input); got != tc.want {
|
||||
t.Errorf("ExtractVersionPrefix(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helper functions ----
|
||||
|
||||
func TestFirstNonEmpty(t *testing.T) {
|
||||
if got := firstNonEmpty("", "", "third", "fourth"); got != "third" {
|
||||
t.Errorf("expected third, got %q", got)
|
||||
}
|
||||
|
||||
if got := firstNonEmpty("", ""); got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBaseURL(t *testing.T) {
|
||||
if got := normalizeBaseURL("http://example.com"); got != "http://example.com/" {
|
||||
t.Errorf("expected trailing slash, got %q", got)
|
||||
}
|
||||
|
||||
if got := normalizeBaseURL("http://example.com/"); got != "http://example.com/" {
|
||||
t.Errorf("expected no double slash, got %q", got)
|
||||
}
|
||||
|
||||
if got := normalizeBaseURL(""); got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeB64(t *testing.T) {
|
||||
original := "https://streaming.bose.com"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(original))
|
||||
|
||||
if got := decodeB64(encoded); got != original {
|
||||
t.Errorf("decodeB64 failed: got %q, want %q", got, original)
|
||||
}
|
||||
|
||||
if got := decodeB64(""); got != "" {
|
||||
t.Errorf("expected empty for empty input, got %q", got)
|
||||
}
|
||||
|
||||
// Not valid base64 → returned as-is
|
||||
if got := decodeB64("plain text"); got != "plain text" {
|
||||
t.Errorf("expected plain text returned as-is, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- LoadConfig / RewriteConfigURLs ----
|
||||
|
||||
func makeConfigJSON(t *testing.T, defaults map[string]string) string {
|
||||
t.Helper()
|
||||
|
||||
// Encode each default value as base64
|
||||
encodedDefaults := make(map[string]interface{})
|
||||
for k, v := range defaults {
|
||||
encodedDefaults[k] = base64.StdEncoding.EncodeToString([]byte(v))
|
||||
}
|
||||
|
||||
raw := map[string]interface{}{
|
||||
"app_versions": map[string]interface{}{
|
||||
"bose_app": "27.0.13-release",
|
||||
"bose_protocol": "1.0",
|
||||
},
|
||||
"api_versions": map[string]interface{}{
|
||||
"bose_streaming": "1.2",
|
||||
"bose_customer": "1.3",
|
||||
},
|
||||
"default": encodedDefaults,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func writeStockholmConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
jsonDir := filepath.Join(dir, "json")
|
||||
|
||||
if err := os.MkdirAll(jsonDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(jsonDir, "config.json"), []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write config.json: %v", err)
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestLoadConfig_ParsesVersions(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d0": "https://streaming.bose.com/marge/",
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.AppVersion != "27.0.13-release" {
|
||||
t.Errorf("AppVersion = %q, want %q", cfg.AppVersion, "27.0.13-release")
|
||||
}
|
||||
|
||||
if cfg.StreamingVersion != "1.2" {
|
||||
t.Errorf("StreamingVersion = %q, want %q", cfg.StreamingVersion, "1.2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_DefaultsForMissingAPIVersions(t *testing.T) {
|
||||
// api_versions absent → defaults to "1.0"
|
||||
raw := map[string]interface{}{
|
||||
"app_versions": map[string]interface{}{
|
||||
"bose_app": "27.0",
|
||||
"bose_protocol": "1.0",
|
||||
},
|
||||
"api_versions": map[string]interface{}{},
|
||||
"default": map[string]interface{}{},
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
|
||||
dir := writeStockholmConfig(t, string(data))
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.StreamingVersion != "1.0" {
|
||||
t.Errorf("expected default StreamingVersion=1.0, got %q", cfg.StreamingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteConfigURLs_ReplacesHostnames(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d0": "https://streaming.bose.com/marge/",
|
||||
"d1": "https://downloads.bose.com/updates/",
|
||||
"d3": "https://content.api.bose.io/registry",
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
backendURL := "http://myserver:8000"
|
||||
if err := RewriteConfigURLs(dir, backendURL, backendURL, backendURL); err != nil {
|
||||
t.Fatalf("RewriteConfigURLs: %v", err)
|
||||
}
|
||||
|
||||
// Reload and verify
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after rewrite: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.DefaultMargeURL, backendURL) {
|
||||
t.Errorf("DefaultMargeURL = %q, expected prefix %q", cfg.DefaultMargeURL, backendURL)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.DefaultUpdateURL, backendURL) {
|
||||
t.Errorf("DefaultUpdateURL = %q, expected prefix %q", cfg.DefaultUpdateURL, backendURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteConfigURLs_MargeURLUsedForStreaming(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d0": "https://streaming.bose.com/",
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
backendURL := "http://backend:8000"
|
||||
margeURL := "http://backend:8000/marge"
|
||||
|
||||
if err := RewriteConfigURLs(dir, backendURL, margeURL, backendURL); err != nil {
|
||||
t.Fatalf("RewriteConfigURLs: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after rewrite: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.DefaultMargeURL, margeURL) {
|
||||
t.Errorf("DefaultMargeURL = %q, expected prefix %q", cfg.DefaultMargeURL, margeURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteConfigURLs_AuthServiceURLHasTrailingSlash(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d6": "oauth", // original Bose placeholder
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
backendURL := "http://backend:8000"
|
||||
|
||||
// Without trailing slash — function should add it.
|
||||
if err := RewriteConfigURLs(dir, backendURL, backendURL, backendURL); err != nil {
|
||||
t.Fatalf("RewriteConfigURLs: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after rewrite: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(cfg.AuthServiceURL, "/") {
|
||||
t.Errorf("AuthServiceURL = %q, expected trailing slash", cfg.AuthServiceURL)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.AuthServiceURL, backendURL) {
|
||||
t.Errorf("AuthServiceURL = %q, expected prefix %q", cfg.AuthServiceURL, backendURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteConfigURLs_AuthServiceURL_ExplicitValue(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d6": "oauth",
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
backendURL := "http://backend:8000"
|
||||
authURL := "http://auth.backend:8001"
|
||||
|
||||
if err := RewriteConfigURLs(dir, backendURL, backendURL, authURL); err != nil {
|
||||
t.Fatalf("RewriteConfigURLs: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after rewrite: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.AuthServiceURL, authURL) {
|
||||
t.Errorf("AuthServiceURL = %q, expected prefix %q", cfg.AuthServiceURL, authURL)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(cfg.AuthServiceURL, "/") {
|
||||
t.Errorf("AuthServiceURL = %q, expected trailing slash", cfg.AuthServiceURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MediaType helpers ----
|
||||
|
||||
func TestStreamingMediaType(t *testing.T) {
|
||||
cfg := &Config{StreamingVersion: "1.2"}
|
||||
want := "application/vnd.bose.streaming-v1.2+xml"
|
||||
|
||||
if got := cfg.StreamingMediaType(); got != want {
|
||||
t.Errorf("StreamingMediaType() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaTypeForPath(t *testing.T) {
|
||||
cfg := &Config{StreamingVersion: "1.2", CustomerVersion: "1.3"}
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/customer/login", "application/vnd.bose.customer-v1.3+xml"},
|
||||
{"/streaming/content", "application/vnd.bose.streaming-v1.2+xml"},
|
||||
{"/info", "application/xml"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := cfg.MediaTypeForPath(tc.path); got != tc.want {
|
||||
t.Errorf("MediaTypeForPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- LoadBackendConfig ----
|
||||
|
||||
func TestLoadBackendConfig_MissingFile_ReturnsDefaults(t *testing.T) {
|
||||
cfg := LoadBackendConfig(t.TempDir())
|
||||
|
||||
if cfg.FrontendLoggingLevel != 0 {
|
||||
t.Errorf("expected default FrontendLoggingLevel=0, got %d", cfg.FrontendLoggingLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackendConfig_ParsesLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgDir := filepath.Join(dir, "backend", "config")
|
||||
_ = os.MkdirAll(cfgDir, 0755)
|
||||
_ = os.WriteFile(filepath.Join(cfgDir, "backend-config.json"),
|
||||
[]byte(`{"frontendLoggingLevel": 3}`), 0644)
|
||||
|
||||
cfg := LoadBackendConfig(dir)
|
||||
|
||||
if cfg.FrontendLoggingLevel != 3 {
|
||||
t.Errorf("expected FrontendLoggingLevel=3, got %d", cfg.FrontendLoggingLevel)
|
||||
}
|
||||
|
||||
if !cfg.ShouldEnableFrontendDebug() {
|
||||
t.Error("expected ShouldEnableFrontendDebug=true for level 3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackendConfig_NegativeLevel_Clamped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgDir := filepath.Join(dir, "backend", "config")
|
||||
_ = os.MkdirAll(cfgDir, 0755)
|
||||
_ = os.WriteFile(filepath.Join(cfgDir, "backend-config.json"),
|
||||
[]byte(`{"frontendLoggingLevel": -1}`), 0644)
|
||||
|
||||
cfg := LoadBackendConfig(dir)
|
||||
|
||||
if cfg.FrontendLoggingLevel != 0 {
|
||||
t.Errorf("expected clamped FrontendLoggingLevel=0, got %d", cfg.FrontendLoggingLevel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ssdpAddr = "239.255.255.250:1900"
|
||||
rendererST = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
||||
serverST = "urn:schemas-upnp-org:device:MediaServer:1"
|
||||
ssdpProbes = 3
|
||||
ssdpProbeIntervalMS = 350
|
||||
ssdpGraceMS = 1250
|
||||
ssdpReceiveSliceMS = 250
|
||||
ssdpMX = 1
|
||||
)
|
||||
|
||||
// RendererDevice is the payload pushed to the browser for a discovered speaker.
|
||||
type RendererDevice struct {
|
||||
UID string `json:"uID"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// ServerDevice is the payload pushed for a discovered HRMS media server.
|
||||
type ServerDevice struct {
|
||||
UID string `json:"uID"`
|
||||
IP string `json:"ip"`
|
||||
Port string `json:"port"`
|
||||
}
|
||||
|
||||
// infoXML is used to unmarshal /info responses from speakers.
|
||||
type infoXML struct {
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
MargeAccountUUID string `xml:"margeAccountUUID"`
|
||||
}
|
||||
|
||||
// DiscoverRenderers performs SSDP MediaRenderer:1 discovery, fetches /info from
|
||||
// each speaker, optionally filters by expectedAccountID, and calls onDevice for
|
||||
// each accepted speaker incrementally.
|
||||
func DiscoverRenderers(expectedAccountID string, onDevice func(RendererDevice)) []RendererDevice {
|
||||
responses := ssdpSearch(rendererST)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
var results []RendererDevice
|
||||
|
||||
for _, resp := range responses {
|
||||
host := hostFromSSDPResponse(resp)
|
||||
if host == "" || seen[host] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[host] = true
|
||||
|
||||
info, err := fetchSpeakerInfo(host)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm SSDP] Failed to fetch /info from %s: %v", host, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if expectedAccountID != "" && info.MargeAccountUUID != expectedAccountID {
|
||||
log.Printf("[Stockholm SSDP] Skipping %s: account %q != %q", host, info.MargeAccountUUID, expectedAccountID)
|
||||
continue
|
||||
}
|
||||
|
||||
uid := strings.ToUpper(info.DeviceID)
|
||||
if uid == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
d := RendererDevice{UID: uid, IP: host}
|
||||
results = append(results, d)
|
||||
|
||||
if onDevice != nil {
|
||||
onDevice(d)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// DiscoverServers performs SSDP MediaServer:1 discovery and returns all found servers.
|
||||
func DiscoverServers() []ServerDevice {
|
||||
responses := ssdpSearch(serverST)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
var results []ServerDevice
|
||||
|
||||
for _, resp := range responses {
|
||||
location := resp["location"]
|
||||
if location == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
u, err := url.Parse(location)
|
||||
if err != nil || u.Host == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
|
||||
portStr := u.Port()
|
||||
if portStr == "" {
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
portStr = "443"
|
||||
default:
|
||||
portStr = "80"
|
||||
}
|
||||
}
|
||||
|
||||
key := host + ":" + portStr
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[key] = true
|
||||
|
||||
uid := normalizeUSN(resp["usn"], key)
|
||||
results = append(results, ServerDevice{UID: uid, IP: host, Port: portStr})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ssdpSearch sends SSDP M-SEARCH requests and returns raw response header maps.
|
||||
func ssdpSearch(searchTarget string) []map[string]string {
|
||||
ifaces := discoveryInterfaces()
|
||||
|
||||
if len(ifaces) == 0 {
|
||||
return searchOnInterface(searchTarget, nil)
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
results := searchOnInterface(searchTarget, &iface)
|
||||
if len(results) > 0 {
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchOnInterface(searchTarget string, iface *net.Interface) []map[string]string {
|
||||
mcastAddr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var conn *net.UDPConn
|
||||
if iface == nil {
|
||||
conn, err = net.ListenUDP("udp4", &net.UDPAddr{})
|
||||
} else {
|
||||
bindAddr := primaryIPv4(iface)
|
||||
if bindAddr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: bindAddr})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
payload := []byte(buildMSearch(searchTarget))
|
||||
seen := make(map[string]map[string]string)
|
||||
|
||||
// 3 probes
|
||||
deadline := time.Now().Add(time.Duration(ssdpProbes*ssdpProbeIntervalMS+ssdpGraceMS) * time.Millisecond)
|
||||
_ = conn.SetReadDeadline(deadline)
|
||||
|
||||
for probe := 0; probe < ssdpProbes; probe++ {
|
||||
if _, err := conn.WriteToUDP(payload, mcastAddr); err != nil {
|
||||
log.Printf("[Stockholm SSDP] Send error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
collectUntil(conn, searchTarget, seen, time.Now().Add(time.Duration(ssdpProbeIntervalMS)*time.Millisecond))
|
||||
}
|
||||
|
||||
collectUntil(conn, searchTarget, seen, time.Now().Add(time.Duration(ssdpGraceMS)*time.Millisecond))
|
||||
|
||||
result := make([]map[string]string, 0, len(seen))
|
||||
for _, v := range seen {
|
||||
result = append(result, v)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func collectUntil(conn *net.UDPConn, searchTarget string, seen map[string]map[string]string, until time.Time) {
|
||||
buf := make([]byte, 8192)
|
||||
|
||||
for {
|
||||
remaining := time.Until(until)
|
||||
if remaining <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
slice := time.Duration(ssdpReceiveSliceMS) * time.Millisecond
|
||||
if slice > remaining {
|
||||
slice = remaining
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(slice))
|
||||
|
||||
n, remoteAddr, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
headers := parseSSDPHeaders(buf[:n], remoteAddr.IP.String())
|
||||
|
||||
if !matchesST(headers, searchTarget) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := responseKey(headers)
|
||||
if _, exists := seen[key]; !exists {
|
||||
seen[key] = headers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseSSDPHeaders(data []byte, remoteIP string) map[string]string {
|
||||
out := map[string]string{"remote-ip": remoteIP}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
|
||||
idx := strings.IndexByte(line, ':')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
k := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||
v := strings.TrimSpace(line[idx+1:])
|
||||
|
||||
if _, exists := out[k]; !exists {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func matchesST(resp map[string]string, target string) bool {
|
||||
if st := resp["st"]; strings.EqualFold(st, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
if usn := resp["usn"]; strings.Contains(strings.ToLower(usn), strings.ToLower(target)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func responseKey(resp map[string]string) string {
|
||||
return resp["usn"] + "|" + resp["location"] + "|" + resp["remote-ip"]
|
||||
}
|
||||
|
||||
func hostFromSSDPResponse(resp map[string]string) string {
|
||||
if loc := resp["location"]; loc != "" {
|
||||
if u, err := url.Parse(loc); err == nil && u.Host != "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
}
|
||||
|
||||
return resp["remote-ip"]
|
||||
}
|
||||
|
||||
func fetchSpeakerInfo(host string) (*infoXML, error) {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
resp, err := client.Get(fmt.Sprintf("http://%s:8090/info", host))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info infoXML
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func normalizeUSN(usn, fallback string) string {
|
||||
if usn == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Strip "::urn:..." suffix
|
||||
if i := strings.Index(usn, "::"); i >= 0 {
|
||||
usn = usn[:i]
|
||||
}
|
||||
|
||||
// Strip "uuid:" prefix
|
||||
if strings.HasPrefix(strings.ToLower(usn), "uuid:") {
|
||||
usn = usn[5:]
|
||||
}
|
||||
|
||||
if usn == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return usn
|
||||
}
|
||||
|
||||
func buildMSearch(st string) string {
|
||||
return strings.Join([]string{
|
||||
"M-SEARCH * HTTP/1.1",
|
||||
"Host:239.255.255.250:1900",
|
||||
`Man:"ssdp:discover"`,
|
||||
fmt.Sprintf("MX:%d", ssdpMX),
|
||||
"ST:" + st,
|
||||
"",
|
||||
"",
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
// discoveryInterfaces returns suitable network interfaces sorted by priority:
|
||||
// ethernet/en* first, then wifi/wl*, then others. Loopback/virtual/docker etc. are excluded.
|
||||
func discoveryInterfaces() []net.Interface {
|
||||
all, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ifaces []net.Interface
|
||||
|
||||
for _, iface := range all {
|
||||
if !isDiscoveryInterface(iface) {
|
||||
continue
|
||||
}
|
||||
|
||||
ifaces = append(ifaces, iface)
|
||||
}
|
||||
|
||||
// Sort: ethernet first (priority 0), wifi (1), others (2)
|
||||
for i := 0; i < len(ifaces); i++ {
|
||||
for j := i + 1; j < len(ifaces); j++ {
|
||||
if interfacePriority(ifaces[i]) > interfacePriority(ifaces[j]) {
|
||||
ifaces[i], ifaces[j] = ifaces[j], ifaces[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ifaces
|
||||
}
|
||||
|
||||
func isDiscoveryInterface(iface net.Interface) bool {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if iface.Flags&net.FlagMulticast == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
desc := strings.ToLower(iface.Name + " " + iface.Name)
|
||||
for _, banned := range []string{"docker", "vbox", "vmware", "hyper-v", "loopback", "bluetooth", "teredo", "tunnel"} {
|
||||
if strings.Contains(desc, banned) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return primaryIPv4(&iface) != nil
|
||||
}
|
||||
|
||||
func interfacePriority(iface net.Interface) int {
|
||||
name := strings.ToLower(iface.Name)
|
||||
if strings.HasPrefix(name, "eth") || strings.HasPrefix(name, "en") {
|
||||
return 0
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "wl") || strings.Contains(name, "wifi") || strings.Contains(name, "wlan") {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
func primaryIPv4(iface *net.Interface) net.IP {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
|
||||
if ip4 := ip.To4(); ip4 != nil && !ip4.IsLoopback() {
|
||||
return ip4
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler is the main entry point for the Stockholm frontend backend.
|
||||
type Handler struct {
|
||||
cfg *Config
|
||||
backendCfg *BackendConfig
|
||||
state *NativeState
|
||||
bridge *Bridge
|
||||
stockholmDir string
|
||||
}
|
||||
|
||||
// New initialises and returns a Stockholm Handler.
|
||||
//
|
||||
// stockholmDir is the path to the extracted Stockholm frontend (contains index.html).
|
||||
// workspaceRoot is used to locate backend/state and backend/config directories.
|
||||
// backendURL is the external URL of this service (used for config URL rewriting).
|
||||
// basePath is the URL prefix at which the Stockholm UI is mounted (e.g. "/stockholm");
|
||||
// pass "" to serve at the root.
|
||||
func New(stockholmDir, workspaceRoot, backendURL, basePath string) (*Handler, error) {
|
||||
if _, err := os.Stat(stockholmDir); err != nil {
|
||||
return nil, fmt.Errorf("stockholm dir not found at %q: %w", stockholmDir, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(stockholmDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load stockholm config: %w", err)
|
||||
}
|
||||
|
||||
if backendURL != "" {
|
||||
margeURL := firstNonEmpty(os.Getenv("MARGE_URL"), backendURL)
|
||||
|
||||
authServiceURL := firstNonEmpty(os.Getenv("AUTH_SERVICE_URL"), backendURL)
|
||||
if err := RewriteConfigURLs(stockholmDir, backendURL, margeURL, authServiceURL); err != nil {
|
||||
log.Printf("[Stockholm] Warning: failed to rewrite config URLs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
backendCfg := LoadBackendConfig(workspaceRoot)
|
||||
|
||||
stateDir := filepath.Join(workspaceRoot, "backend", "state")
|
||||
if err := os.MkdirAll(stateDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create state dir: %w", err)
|
||||
}
|
||||
|
||||
state := NewNativeState(stateDir)
|
||||
if err := state.Load(); err != nil {
|
||||
log.Printf("[Stockholm] Warning: failed to load native state: %v", err)
|
||||
}
|
||||
|
||||
// Normalise basePath: no trailing slash, must start with "/" or be empty.
|
||||
if basePath != "" && !strings.HasPrefix(basePath, "/") {
|
||||
basePath = "/" + basePath
|
||||
}
|
||||
|
||||
basePath = strings.TrimRight(basePath, "/")
|
||||
|
||||
// Defence in depth: basePath is operator-provided (CLI flag /
|
||||
// STOCKHOLM_BASE_PATH env var), not request input — but if it
|
||||
// were ever set to "//evil.com" (typo or hostile env injection)
|
||||
// the bare-path redirect below would go scheme-relative to
|
||||
// evil.com. Reject any leading-double-slash and any backslash
|
||||
// so the redirect target can only ever be an absolute local
|
||||
// path. CodeQL go/bad-redirect-check raised the original
|
||||
// concern.
|
||||
if strings.HasPrefix(basePath, "//") || strings.HasPrefix(basePath, "/\\") || strings.ContainsAny(basePath, "\\") {
|
||||
return nil, fmt.Errorf("invalid stockholm base path %q: must be an absolute path starting with a single '/'", basePath)
|
||||
}
|
||||
|
||||
cfg.BasePath = basePath
|
||||
|
||||
state.SeedFromEnv(cfg)
|
||||
|
||||
bridge := newBridge(cfg, state)
|
||||
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
backendCfg: backendCfg,
|
||||
state: state,
|
||||
bridge: bridge,
|
||||
stockholmDir: stockholmDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Mount registers all Stockholm routes on the given chi router.
|
||||
// API routes (/api/native/*, /api/http-proxy) are registered under cfg.BasePath
|
||||
// because the patched JS uses window.__stockholmBase as a prefix for all API calls.
|
||||
// Static content is served under cfg.BasePath (e.g. /stockholm) if set,
|
||||
// otherwise at the root.
|
||||
func (h *Handler) Mount(r chi.Router) {
|
||||
apiBase := h.cfg.BasePath
|
||||
r.Post(apiBase+"/api/native/appSend", h.bridge.HandleAppSend)
|
||||
r.Get(apiBase+"/api/native/runQueue", h.bridge.HandleRunQueue)
|
||||
r.HandleFunc(apiBase+"/api/http-proxy", h.handleProxy)
|
||||
|
||||
if h.cfg.BasePath != "" {
|
||||
// Redirect bare /stockholm to /stockholm/ so the browser sets the correct
|
||||
// base URL for relative asset references.
|
||||
r.Get(h.cfg.BasePath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, h.cfg.BasePath+"/", http.StatusMovedPermanently)
|
||||
})
|
||||
// Strip the base path prefix before passing to handleStatic so that
|
||||
// resolveStaticFile sees paths like "/" or "/index.html", not "/stockholm/".
|
||||
// r.Route does NOT strip r.URL.Path, so we must use http.StripPrefix explicitly.
|
||||
stripped := http.StripPrefix(h.cfg.BasePath, http.HandlerFunc(h.handleStatic))
|
||||
r.Get(h.cfg.BasePath+"/", stripped.ServeHTTP)
|
||||
r.Head(h.cfg.BasePath+"/", stripped.ServeHTTP)
|
||||
r.Get(h.cfg.BasePath+"/*", stripped.ServeHTTP)
|
||||
r.Head(h.cfg.BasePath+"/*", stripped.ServeHTTP)
|
||||
} else {
|
||||
// Serve static content at the root (catch-all at the end).
|
||||
r.Get("/*", h.handleStatic)
|
||||
r.Head("/*", h.handleStatic)
|
||||
r.Get("/", h.handleStatic)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
HandleProxy(w, r, h.cfg, h.state)
|
||||
}
|
||||
|
||||
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
ServeStatic(w, r, h.stockholmDir, h.backendCfg, h.state, h.cfg)
|
||||
}
|
||||
|
||||
// HandleStatic is the exported form of handleStatic, needed when mounting the
|
||||
// Stockholm static handler inside sub-routers (e.g. to resolve the /setup/ path
|
||||
// collision between the management API and the Stockholm setup wizard pages).
|
||||
func (h *Handler) HandleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
ServeStatic(w, r, h.stockholmDir, h.backendCfg, h.state, h.cfg)
|
||||
}
|
||||
|
||||
// Config returns the loaded Stockholm config (for integration with the proxy handler
|
||||
// that may need to inject BMX/marge headers).
|
||||
func (h *Handler) Config() *Config {
|
||||
return h.cfg
|
||||
}
|
||||
|
||||
// State returns the NativeState (for integration with handlers that need to read
|
||||
// auth tokens or account IDs).
|
||||
func (h *Handler) State() *NativeState {
|
||||
return h.state
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var blockedRequestHeaders = map[string]bool{
|
||||
"access-control-request-headers": true,
|
||||
"access-control-request-method": true,
|
||||
"connection": true,
|
||||
"content-length": true,
|
||||
"cookie": true,
|
||||
"forwarded": true,
|
||||
"host": true,
|
||||
"http2-settings": true,
|
||||
"keep-alive": true,
|
||||
"origin": true,
|
||||
"proxy-authenticate": true,
|
||||
"proxy-authorization": true,
|
||||
"referer": true,
|
||||
"sec-ch-ua": true,
|
||||
"sec-ch-ua-mobile": true,
|
||||
"sec-ch-ua-platform": true,
|
||||
"sec-fetch-dest": true,
|
||||
"sec-fetch-mode": true,
|
||||
"sec-fetch-site": true,
|
||||
"sec-fetch-user": true,
|
||||
"te": true,
|
||||
"trailer": true,
|
||||
"transfer-encoding": true,
|
||||
"upgrade": true,
|
||||
"x-forwarded-for": true,
|
||||
"x-forwarded-host": true,
|
||||
"x-forwarded-port": true,
|
||||
"x-forwarded-proto": true,
|
||||
"x-real-ip": true,
|
||||
"x-requested-with": true,
|
||||
}
|
||||
|
||||
var blockedResponseHeaders = map[string]bool{
|
||||
"access-control-allow-credentials": true,
|
||||
"access-control-allow-headers": true,
|
||||
"access-control-allow-methods": true,
|
||||
"access-control-allow-origin": true,
|
||||
"access-control-expose-headers": true,
|
||||
"access-control-max-age": true,
|
||||
"connection": true,
|
||||
"content-length": true,
|
||||
"keep-alive": true,
|
||||
"proxy-authenticate": true,
|
||||
"proxy-authorization": true,
|
||||
"set-cookie": true,
|
||||
"set-cookie2": true,
|
||||
"te": true,
|
||||
"trailer": true,
|
||||
"transfer-encoding": true,
|
||||
"upgrade": true,
|
||||
}
|
||||
|
||||
var proxyHTTPClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return nil // follow redirects
|
||||
},
|
||||
}
|
||||
|
||||
// HandleProxy serves the /api/http-proxy endpoint.
|
||||
func HandleProxy(w http.ResponseWriter, r *http.Request, cfg *Config, state *NativeState) {
|
||||
encodedTarget := r.URL.Query().Get("url")
|
||||
if encodedTarget == "" {
|
||||
http.Error(w, "Missing url query parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
decodedTarget, err := url.QueryUnescape(encodedTarget)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid proxy target encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
target, err := url.Parse(decodedTarget)
|
||||
if err != nil || target.Host == "" {
|
||||
http.Error(w, "Invalid proxy target", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(target.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
http.Error(w, "Unsupported proxy target", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if isProxyLoop(r, target) {
|
||||
http.Error(w, "Refusing to proxy proxy endpoint", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
effectiveTarget := applyOverrideURL(target, cfg, state)
|
||||
|
||||
resp, err := executeProxyRequest(r, effectiveTarget, body, cfg, state)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm proxy] %s %s failed: %v", r.Method, effectiveTarget, err)
|
||||
http.Error(w, "Proxy request failed", http.StatusBadGateway)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read proxy response", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Login retry logic
|
||||
if isLoginRequest(r.Method, effectiveTarget) {
|
||||
resp, respBody, effectiveTarget = handleLoginRetry(r, effectiveTarget, body, resp, respBody, cfg, state)
|
||||
captureSuccessfulLogin(resp, respBody, state)
|
||||
}
|
||||
|
||||
captureRefreshedToken(effectiveTarget, resp, cfg, state)
|
||||
relayProxyResponse(w, r.Method, resp, respBody)
|
||||
}
|
||||
|
||||
func executeProxyRequest(r *http.Request, target *url.URL, body []byte, cfg *Config, state *NativeState) (*http.Response, error) {
|
||||
method := strings.ToUpper(r.Method)
|
||||
|
||||
var bodyReader io.Reader
|
||||
if method != "GET" && method != "HEAD" && len(body) > 0 {
|
||||
bodyReader = strings.NewReader(string(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), method, target.String(), bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Forward allowed request headers
|
||||
for k, vals := range r.Header {
|
||||
if blockedRequestHeaders[strings.ToLower(k)] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range sanitizeHeaderValues(vals) {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject backend headers
|
||||
injectBackendHeaders(req, target, cfg, state)
|
||||
|
||||
return proxyHTTPClient.Do(req)
|
||||
}
|
||||
|
||||
func injectBackendHeaders(req *http.Request, target *url.URL, cfg *Config, state *NativeState) {
|
||||
host := target.Hostname()
|
||||
path := target.Path
|
||||
|
||||
if cfg.IsBmxTarget(host) {
|
||||
injectIfMissing(req, "x-bmx-api-key", cfg.EncryptedBmxToken)
|
||||
injectIfMissing(req, "x-software-version", cfg.AppVersion)
|
||||
}
|
||||
|
||||
if cfg.IsMargeTarget(host, path) {
|
||||
mediaType := cfg.MediaTypeForPath(path)
|
||||
injectIfMissing(req, "Accept", mediaType)
|
||||
injectIfMissing(req, "Content-Type", mediaType)
|
||||
injectIfMissing(req, "ClientType", "SOUNDTOUCH_COMPUTER_APP")
|
||||
injectIfMissing(req, "GUID", firstNonEmpty(state.Get("guid"), state.Get("deviceGuid")))
|
||||
injectIfMissing(req, "version_NativeFrameVersion", state.Get("nativeFrameVersion"))
|
||||
injectIfMissing(req, "version_StockholmVersion", cfg.AppVersion)
|
||||
injectIfMissing(req, "version_ProtocolVersion", cfg.ProtocolVersion)
|
||||
|
||||
if cfg.MargeServerKeyHeader != "" && cfg.MargeServerKey != "" {
|
||||
injectIfMissing(req, cfg.MargeServerKeyHeader, cfg.MargeServerKey)
|
||||
}
|
||||
|
||||
if shouldInjectAuth(path) {
|
||||
injectIfMissing(req, "Authorization", state.Get("margeAuthToken"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldInjectAuth(path string) bool {
|
||||
p := strings.ToLower(path)
|
||||
if strings.HasSuffix(p, "/streaming/account/login") {
|
||||
return false
|
||||
}
|
||||
|
||||
if p == "/streaming/account" || p == "/streaming/account/" {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(p, "/streaming/account/email/") && strings.HasSuffix(p, "/environment") {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(p, "/customer/account/password/email/") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func injectIfMissing(req *http.Request, name, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Header.Get(name) != "" {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
|
||||
func sanitizeHeaderValues(vals []string) []string {
|
||||
out := make([]string, 0, len(vals))
|
||||
for _, v := range vals {
|
||||
t := strings.TrimSpace(v)
|
||||
if t == "" || strings.EqualFold(t, "null") || strings.EqualFold(t, "undefined") {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, t)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func handleLoginRetry(
|
||||
r *http.Request,
|
||||
target *url.URL,
|
||||
body []byte,
|
||||
resp *http.Response,
|
||||
respBody []byte,
|
||||
cfg *Config,
|
||||
state *NativeState,
|
||||
) (*http.Response, []byte, *url.URL) {
|
||||
if extractXMLStatusCode(respBody) != "4033" {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
email, password := parseLoginCredentials(body)
|
||||
if email == "" {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
env := fetchEnvironment(r, target, email, password, cfg, state)
|
||||
if env == nil || env.streamingURL == "" {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
state.PutMany(map[string]string{
|
||||
"overrideMargeURL": normalizeBaseURL(env.streamingURL),
|
||||
"overrideUpdateURL": normalizeBaseURL(env.updateURL),
|
||||
})
|
||||
|
||||
retryTarget := buildURIFromBase(env.streamingURL, target.Path, target.RawQuery)
|
||||
if retryTarget == nil {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
retryResp, err := executeProxyRequest(r, retryTarget, body, cfg, state)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm proxy] Login retry failed: %v", err)
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
retryBody, err := io.ReadAll(retryResp.Body)
|
||||
_ = retryResp.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
return retryResp, retryBody, retryTarget
|
||||
}
|
||||
|
||||
func fetchEnvironment(r *http.Request, loginTarget *url.URL, email, password string, cfg *Config, state *NativeState) *environmentInfo {
|
||||
// Build environment URL
|
||||
prefix := margePathPrefix(loginTarget.Path)
|
||||
envPath := prefix + "/streaming/account/email/" + url.PathEscape(email) + "/environment"
|
||||
|
||||
envTarget := buildURIFromBase(loginTarget.Scheme+"://"+loginTarget.Host+"/", envPath, "")
|
||||
if envTarget == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
envReq, err := http.NewRequestWithContext(r.Context(), "GET", envTarget.String(), nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy allowed headers from original request
|
||||
for k, vals := range r.Header {
|
||||
if blockedRequestHeaders[strings.ToLower(k)] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range sanitizeHeaderValues(vals) {
|
||||
envReq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
raw := email + ":" + password
|
||||
envReq.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw)))
|
||||
injectBackendHeaders(envReq, envTarget, cfg, state)
|
||||
|
||||
envResp, err := proxyHTTPClient.Do(envReq)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() { _ = envResp.Body.Close() }()
|
||||
|
||||
if envResp.StatusCode != 200 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(envResp.Body)
|
||||
|
||||
return extractEnvironment(body)
|
||||
}
|
||||
|
||||
func captureSuccessfulLogin(resp *http.Response, body []byte, state *NativeState) {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]string)
|
||||
|
||||
if accountID := extractXMLAccountID(body); accountID != "" {
|
||||
updates["margeAccountID"] = accountID
|
||||
}
|
||||
|
||||
if creds := resp.Header.Get("Credentials"); creds != "" {
|
||||
updates["margeAuthToken"] = creds
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
state.PutMany(updates)
|
||||
}
|
||||
}
|
||||
|
||||
func captureRefreshedToken(target *url.URL, resp *http.Response, cfg *Config, state *NativeState) {
|
||||
if target == nil || !cfg.IsMargeTarget(target.Hostname(), target.Path) {
|
||||
return
|
||||
}
|
||||
|
||||
if refreshed := resp.Header.Get("Refresh"); refreshed != "" {
|
||||
state.Set("margeAuthToken", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func relayProxyResponse(w http.ResponseWriter, method string, resp *http.Response, body []byte) {
|
||||
for k, vals := range resp.Header {
|
||||
kl := strings.ToLower(k)
|
||||
if kl == "" || strings.HasPrefix(kl, ":") || blockedResponseHeaders[kl] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range vals {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
bodyAllowed := method != "HEAD" && resp.StatusCode != 204 && resp.StatusCode != 304
|
||||
if bodyAllowed {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
}
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
if bodyAllowed {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
func isLoginRequest(method string, target *url.URL) bool {
|
||||
return strings.EqualFold(method, "POST") &&
|
||||
target != nil &&
|
||||
strings.HasSuffix(strings.ToLower(target.Path), "/streaming/account/login")
|
||||
}
|
||||
|
||||
func isProxyLoop(r *http.Request, target *url.URL) bool {
|
||||
if !strings.HasPrefix(target.Path, "/api/http-proxy") {
|
||||
return false
|
||||
}
|
||||
|
||||
host := target.Hostname()
|
||||
targetPort := target.Port()
|
||||
|
||||
localHost, localPort, _ := net.SplitHostPort(r.Host)
|
||||
if localHost == "" {
|
||||
localHost = r.Host
|
||||
}
|
||||
|
||||
if targetPort != "" && targetPort == localPort {
|
||||
if strings.EqualFold(host, localHost) ||
|
||||
strings.EqualFold(host, "localhost") ||
|
||||
host == "127.0.0.1" || host == "::1" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also check forwarded headers
|
||||
extHost := resolveExternalHost(r)
|
||||
extPort := resolveExternalPort(r)
|
||||
|
||||
if extHost != "" && strings.EqualFold(host, extHost) {
|
||||
tp, _ := parsePort(target.Port(), target.Scheme)
|
||||
if tp == extPort {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveExternalHost(r *http.Request) string {
|
||||
if v := r.Header.Get("X-Forwarded-Host"); v != "" {
|
||||
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
||||
return v[:idx]
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
if v := r.Host; v != "" {
|
||||
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
||||
return v[:idx]
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveExternalPort(r *http.Request) int {
|
||||
if v := r.Header.Get("X-Forwarded-Port"); v != "" {
|
||||
if p, err := parsePort(v, ""); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.Header.Get("X-Forwarded-Host"); v != "" {
|
||||
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
||||
if p, err := parsePort(v[idx+1:], ""); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.Host; v != "" {
|
||||
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
||||
if p, err := parsePort(v[idx+1:], ""); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
return 443
|
||||
}
|
||||
|
||||
return 80
|
||||
}
|
||||
|
||||
func parsePort(portStr, scheme string) (int, error) {
|
||||
if portStr != "" {
|
||||
var p int
|
||||
|
||||
_, err := fmt.Sscanf(portStr, "%d", &p)
|
||||
|
||||
return p, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(scheme) {
|
||||
case "https":
|
||||
return 443, nil
|
||||
case "http":
|
||||
return 80, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("no port")
|
||||
}
|
||||
|
||||
func applyOverrideURL(target *url.URL, cfg *Config, state *NativeState) *url.URL {
|
||||
if !cfg.IsMargeTarget(target.Hostname(), target.Path) {
|
||||
return target
|
||||
}
|
||||
|
||||
override := normalizeBaseURL(state.Get("overrideMargeURL"))
|
||||
if override == "" {
|
||||
return target
|
||||
}
|
||||
|
||||
result := buildURIFromBase(override, target.Path, target.RawQuery)
|
||||
if result == nil {
|
||||
return target
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func buildURIFromBase(base, path, query string) *url.URL {
|
||||
base = normalizeBaseURL(base)
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
u.Path = path
|
||||
u.RawQuery = query
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func margePathPrefix(path string) string {
|
||||
p := strings.ToLower(path)
|
||||
|
||||
idx := strings.Index(p, "/streaming/")
|
||||
if idx <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return path[:idx]
|
||||
}
|
||||
|
||||
// XML helpers for login retry
|
||||
|
||||
type xmlStatusCode struct {
|
||||
StatusCode string `xml:"status-code"`
|
||||
}
|
||||
|
||||
type xmlAccountAttr struct {
|
||||
ID string `xml:"id,attr"`
|
||||
}
|
||||
|
||||
type xmlLoginBody struct {
|
||||
Username string `xml:"username"`
|
||||
Password string `xml:"password"`
|
||||
}
|
||||
|
||||
type xmlEnvironment struct {
|
||||
StreamingURL string `xml:"streamingURL"`
|
||||
UpdateURL string `xml:"updateURL"`
|
||||
}
|
||||
|
||||
type environmentInfo struct {
|
||||
streamingURL string
|
||||
updateURL string
|
||||
}
|
||||
|
||||
func extractXMLStatusCode(body []byte) string {
|
||||
var v xmlStatusCode
|
||||
if err := xml.Unmarshal(body, &v); err == nil && v.StatusCode != "" {
|
||||
return v.StatusCode
|
||||
}
|
||||
// Try finding in any wrapper element
|
||||
type wrapper struct {
|
||||
StatusCode string `xml:"status-code"`
|
||||
}
|
||||
|
||||
var w wrapper
|
||||
|
||||
_ = xml.Unmarshal(body, &w)
|
||||
|
||||
return w.StatusCode
|
||||
}
|
||||
|
||||
func parseLoginCredentials(body []byte) (email, password string) {
|
||||
var login xmlLoginBody
|
||||
if err := xml.Unmarshal(body, &login); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(login.Username), login.Password
|
||||
}
|
||||
|
||||
func extractXMLAccountID(body []byte) string {
|
||||
type accountWrapper struct {
|
||||
Account xmlAccountAttr `xml:"account"`
|
||||
}
|
||||
|
||||
var w accountWrapper
|
||||
if err := xml.Unmarshal(body, &w); err == nil && w.Account.ID != "" {
|
||||
return w.Account.ID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractEnvironment(body []byte) *environmentInfo {
|
||||
var env xmlEnvironment
|
||||
if err := xml.Unmarshal(body, &env); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if env.StreamingURL == "" && env.UpdateURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &environmentInfo{streamingURL: env.StreamingURL, updateURL: env.UpdateURL}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// NativeState is a flat string key→value store persisted to native-state.json.
|
||||
// Every Set call writes through to disk.
|
||||
type NativeState struct {
|
||||
mu sync.RWMutex
|
||||
data map[string]string
|
||||
path string
|
||||
}
|
||||
|
||||
// NewNativeState creates a NativeState that persists to stateDir/native-state.json.
|
||||
func NewNativeState(stateDir string) *NativeState {
|
||||
return &NativeState{
|
||||
data: make(map[string]string),
|
||||
path: filepath.Join(stateDir, "native-state.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the state file from disk. Non-existent file is not an error.
|
||||
func (s *NativeState) Load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("read native-state: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("parse native-state: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for k, v := range raw {
|
||||
s.data[k] = stringifyScalar(v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedFromEnv seeds Marge auth/account from environment variables and
|
||||
// sets first-run defaults (guid, nativeFrameVersion, authServer, constant.kilo).
|
||||
func (s *NativeState) SeedFromEnv(cfg *Config) {
|
||||
updates := make(map[string]string)
|
||||
|
||||
// Marge session from env
|
||||
if v := firstNonEmpty(os.Getenv("margeAuthToken"), os.Getenv("MARGE_AUTH_TOKEN")); v != "" {
|
||||
updates["margeAuthToken"] = v
|
||||
}
|
||||
|
||||
if v := firstNonEmpty(os.Getenv("margeAccountID"), os.Getenv("MARGE_ACCOUNT_ID")); v != "" {
|
||||
updates["margeAccountID"] = v
|
||||
}
|
||||
|
||||
if s.Get("constant.kilo") == "" {
|
||||
updates["constant.kilo"] = kiloDefaultValue
|
||||
}
|
||||
|
||||
// First-run defaults that require a persisted value
|
||||
if s.Get("authServer") == "" {
|
||||
updates["authServer"] = "0"
|
||||
}
|
||||
|
||||
// GUID: use existing or generate new
|
||||
existingGUID := firstNonEmpty(s.Get("guid"), s.Get("deviceGuid"))
|
||||
if existingGUID == "" {
|
||||
existingGUID = randomHexUUID()
|
||||
}
|
||||
|
||||
if s.Get("guid") == "" {
|
||||
updates["guid"] = existingGUID
|
||||
}
|
||||
|
||||
if s.Get("deviceGuid") == "" {
|
||||
updates["deviceGuid"] = existingGUID
|
||||
}
|
||||
|
||||
// Version info from config
|
||||
if cfg != nil {
|
||||
fullVersion := firstNonEmpty(s.Get("frame_version"), cfg.AppVersion)
|
||||
shortVersion := firstNonEmpty(ExtractVersionPrefix(s.Get("nativeFrameVersion")),
|
||||
ExtractVersionPrefix(s.Get("frame_version")),
|
||||
ExtractVersionPrefix(cfg.AppVersion))
|
||||
|
||||
if s.Get("nativeFrameVersion") == "" && shortVersion != "" {
|
||||
updates["nativeFrameVersion"] = shortVersion
|
||||
}
|
||||
|
||||
if s.Get("frame_version") == "" && fullVersion != "" {
|
||||
updates["frame_version"] = fullVersion
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
s.putMany(updates)
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the value for key, or "" if absent.
|
||||
func (s *NativeState) Get(key string) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.data[key]
|
||||
}
|
||||
|
||||
// Set stores key→value and persists to disk.
|
||||
func (s *NativeState) Set(key, value string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.data[key] = value
|
||||
s.mu.Unlock()
|
||||
s.persist()
|
||||
}
|
||||
|
||||
// PutMany stores multiple key→value pairs and persists once.
|
||||
func (s *NativeState) PutMany(updates map[string]string) {
|
||||
if len(updates) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.putMany(updates)
|
||||
}
|
||||
|
||||
func (s *NativeState) putMany(updates map[string]string) {
|
||||
s.mu.Lock()
|
||||
changed := false
|
||||
|
||||
for k, v := range updates {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if prev, ok := s.data[k]; !ok || prev != v {
|
||||
s.data[k] = v
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if changed {
|
||||
s.persist()
|
||||
}
|
||||
}
|
||||
|
||||
// AuthServer returns the authServer value normalised to "0"–"3".
|
||||
func (s *NativeState) AuthServer() string {
|
||||
v := s.Get("authServer")
|
||||
switch v {
|
||||
case "0", "1", "2", "3":
|
||||
return v
|
||||
default:
|
||||
return "0"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NativeState) persist() {
|
||||
s.mu.RLock()
|
||||
|
||||
snapshot := make(map[string]string, len(s.data))
|
||||
for k, v := range s.data {
|
||||
snapshot[k] = v
|
||||
}
|
||||
|
||||
s.mu.RUnlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
log.Printf("[Stockholm] Failed to create state dir: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm] Failed to marshal native state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(s.path, append(b, '\n'), 0644); err != nil {
|
||||
log.Printf("[Stockholm] Failed to persist native state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func stringifyScalar(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
if t {
|
||||
return "true"
|
||||
}
|
||||
|
||||
return "false"
|
||||
case float64:
|
||||
// JSON numbers decode to float64
|
||||
if t == float64(int64(t)) {
|
||||
return fmt.Sprintf("%d", int64(t))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%g", t)
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNativeState_SetGet(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
state.Set("foo", "bar")
|
||||
|
||||
if got := state.Get("foo"); got != "bar" {
|
||||
t.Errorf("Get(foo) = %q, want bar", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_GetMissing_ReturnsEmpty(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
if got := state.Get("does-not-exist"); got != "" {
|
||||
t.Errorf("expected empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SetEmpty_IsNoOp(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
state.Set("", "value")
|
||||
|
||||
// Empty key should not be stored
|
||||
if got := state.Get(""); got != "" {
|
||||
t.Errorf("expected empty string for empty key, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_PersistsAndLoads(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := NewNativeState(dir)
|
||||
|
||||
state.Set("key1", "val1")
|
||||
state.Set("key2", "val2")
|
||||
|
||||
// Reload from disk
|
||||
state2 := NewNativeState(dir)
|
||||
if err := state2.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if got := state2.Get("key1"); got != "val1" {
|
||||
t.Errorf("after reload, key1 = %q, want val1", got)
|
||||
}
|
||||
|
||||
if got := state2.Get("key2"); got != "val2" {
|
||||
t.Errorf("after reload, key2 = %q, want val2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_Load_MissingFile_IsOK(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
if err := state.Load(); err != nil {
|
||||
t.Errorf("Load on missing file should not error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_Load_CorruptFile_ReturnsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
statePath := filepath.Join(dir, "native-state.json")
|
||||
_ = os.WriteFile(statePath, []byte("not json"), 0644)
|
||||
|
||||
state := NewNativeState(dir)
|
||||
|
||||
if err := state.Load(); err == nil {
|
||||
t.Error("expected error for corrupt JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_PutMany(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
state.PutMany(map[string]string{
|
||||
"a": "1",
|
||||
"b": "2",
|
||||
})
|
||||
|
||||
if got := state.Get("a"); got != "1" {
|
||||
t.Errorf("expected a=1, got %q", got)
|
||||
}
|
||||
|
||||
if got := state.Get("b"); got != "2" {
|
||||
t.Errorf("expected b=2, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_PutMany_Empty_IsNoOp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := NewNativeState(dir)
|
||||
|
||||
state.PutMany(nil)
|
||||
state.PutMany(map[string]string{})
|
||||
|
||||
// State file should not be created for no-op
|
||||
_, err := os.Stat(filepath.Join(dir, "native-state.json"))
|
||||
if err == nil {
|
||||
t.Error("expected no state file to be created for empty PutMany")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_AuthServer_ValidValues(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
for _, v := range []string{"0", "1", "2", "3"} {
|
||||
state.Set("authServer", v)
|
||||
|
||||
if got := state.AuthServer(); got != v {
|
||||
t.Errorf("AuthServer() = %q, want %q", got, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_AuthServer_InvalidDefault(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
state.Set("authServer", "99")
|
||||
|
||||
if got := state.AuthServer(); got != "0" {
|
||||
t.Errorf("expected default 0 for invalid authServer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SeedFromEnv_SetsGUIDAndDefaults(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
state.SeedFromEnv(nil)
|
||||
|
||||
if got := state.Get("guid"); got == "" {
|
||||
t.Error("expected guid to be seeded")
|
||||
}
|
||||
|
||||
if got := state.Get("deviceGuid"); got == "" {
|
||||
t.Error("expected deviceGuid to be seeded")
|
||||
}
|
||||
|
||||
if got := state.Get("authServer"); got != "0" {
|
||||
t.Errorf("expected authServer=0, got %q", got)
|
||||
}
|
||||
|
||||
if got := state.Get("constant.kilo"); got != "a7928d7b43dcd49f0af31e5aeed26458" {
|
||||
t.Errorf("unexpected kilo value: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SeedFromEnv_GUIDConsistent(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
state.SeedFromEnv(nil)
|
||||
|
||||
guid := state.Get("guid")
|
||||
deviceGuid := state.Get("deviceGuid")
|
||||
|
||||
if guid != deviceGuid {
|
||||
t.Errorf("expected guid == deviceGuid, got %q vs %q", guid, deviceGuid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SeedFromEnv_DoesNotOverwriteExistingGUID(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
state.Set("guid", "existing-guid")
|
||||
|
||||
state.SeedFromEnv(nil)
|
||||
|
||||
if got := state.Get("guid"); got != "existing-guid" {
|
||||
t.Errorf("expected existing guid to be preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SeedFromEnv_SetsVersionFromConfig(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{AppVersion: "27.0.13-release"}
|
||||
|
||||
state.SeedFromEnv(cfg)
|
||||
|
||||
if got := state.Get("nativeFrameVersion"); got != "27.0.13" {
|
||||
t.Errorf("expected nativeFrameVersion=27.0.13, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_PersistFileContainsJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := NewNativeState(dir)
|
||||
state.Set("hello", "world")
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "native-state.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read state file: %v", err)
|
||||
}
|
||||
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("state file is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if m["hello"] != "world" {
|
||||
t.Errorf("expected hello=world in state file, got %q", m["hello"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stringifyScalar ----
|
||||
|
||||
func TestStringifyScalar(t *testing.T) {
|
||||
cases := []struct {
|
||||
input interface{}
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{"hello", "hello"},
|
||||
{true, "true"},
|
||||
{false, "false"},
|
||||
{float64(42), "42"},
|
||||
{float64(3.14), "3.14"},
|
||||
{[]int{1, 2}, `[1,2]`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := stringifyScalar(tc.input); got != tc.want {
|
||||
t.Errorf("stringifyScalar(%v) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// contentTypeFor returns the MIME type for a file based on its extension.
|
||||
func contentTypeFor(name string) string {
|
||||
n := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(n, ".html"):
|
||||
return "text/html; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".js"):
|
||||
return "application/javascript; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".css"):
|
||||
return "text/css; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".json"):
|
||||
return "application/json; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".xml"):
|
||||
return "application/xml; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".svg"):
|
||||
return "image/svg+xml"
|
||||
case strings.HasSuffix(n, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(n, ".jpg"), strings.HasSuffix(n, ".jpeg"):
|
||||
return "image/jpeg"
|
||||
case strings.HasSuffix(n, ".gif"):
|
||||
return "image/gif"
|
||||
case strings.HasSuffix(n, ".ttf"):
|
||||
return "font/ttf"
|
||||
case strings.HasSuffix(n, ".otf"):
|
||||
return "font/otf"
|
||||
case strings.HasSuffix(n, ".txt"):
|
||||
return "text/plain; charset=UTF-8"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// ServeStatic handles all static file requests for the Stockholm frontend.
|
||||
func ServeStatic(w http.ResponseWriter, r *http.Request, stockholmDir string, backendCfg *BackendConfig, state *NativeState, cfg *Config) {
|
||||
method := strings.ToUpper(r.Method)
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
file, rel, err := resolveStaticFile(r.URL.Path, stockholmDir)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm static] Path traversal rejected: %s", r.URL.Path)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
info, err := os.Stat(file)
|
||||
if err != nil || info.IsDir() {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ct := contentTypeFor(file)
|
||||
|
||||
if ct == "text/html; charset=UTF-8" && isBootstrapTarget(rel) {
|
||||
body = injectBootstrap(body, state, cfg)
|
||||
}
|
||||
|
||||
// Frontend logging cookie
|
||||
if backendCfg.ShouldEnableFrontendDebug() {
|
||||
w.Header().Add("Set-Cookie", fmt.Sprintf("stockholmFrontendLoggingLevel=%d; Path=/; SameSite=Lax", backendCfg.FrontendLoggingLevel))
|
||||
} else {
|
||||
w.Header().Add("Set-Cookie", "stockholmFrontendLoggingLevel=; Max-Age=0; Path=/; SameSite=Lax")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
if method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func resolveStaticFile(rawPath, stockholmDir string) (filePath, relPath string, err error) {
|
||||
if rawPath == "" || rawPath == "/" {
|
||||
rawPath = "/index.html"
|
||||
}
|
||||
|
||||
// Strip leading slash, resolve relative to stockholmDir
|
||||
clean := filepath.Clean(strings.TrimPrefix(rawPath, "/"))
|
||||
resolved := filepath.Join(stockholmDir, clean)
|
||||
|
||||
// Security: reject path traversal
|
||||
absStockholm, _ := filepath.Abs(stockholmDir)
|
||||
absResolved, _ := filepath.Abs(resolved)
|
||||
|
||||
if !strings.HasPrefix(absResolved+string(filepath.Separator), absStockholm+string(filepath.Separator)) &&
|
||||
absResolved != absStockholm {
|
||||
return "", "", fmt.Errorf("path outside stockholm root")
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(absResolved, absStockholm+string(filepath.Separator))
|
||||
rel = strings.ReplaceAll(rel, string(filepath.Separator), "/")
|
||||
|
||||
// Directory → try index.html
|
||||
info, statErr := os.Stat(resolved)
|
||||
if statErr == nil && info.IsDir() {
|
||||
resolved = filepath.Join(resolved, "index.html")
|
||||
rel = strings.TrimPrefix(resolved, absStockholm+string(filepath.Separator))
|
||||
rel = strings.ReplaceAll(rel, string(filepath.Separator), "/")
|
||||
}
|
||||
|
||||
return resolved, rel, nil
|
||||
}
|
||||
|
||||
func isBootstrapTarget(relPath string) bool {
|
||||
p := strings.ToLower(relPath)
|
||||
return p == "index.html" || p == "setup/index.html"
|
||||
}
|
||||
|
||||
func injectBootstrap(html []byte, state *NativeState, cfg *Config) []byte {
|
||||
content := string(html)
|
||||
|
||||
if strings.Contains(content, "window.StockholmBrowserBootstrap") {
|
||||
return html
|
||||
}
|
||||
|
||||
idx := strings.Index(content, "</head>")
|
||||
if idx < 0 {
|
||||
return html
|
||||
}
|
||||
|
||||
script := buildBootstrapScript(state, cfg)
|
||||
injected := content[:idx] + script + content[idx:]
|
||||
|
||||
return []byte(injected)
|
||||
}
|
||||
|
||||
func buildBootstrapScript(state *NativeState, cfg *Config) string {
|
||||
guid := firstNonEmpty(state.Get("guid"), state.Get("deviceGuid"))
|
||||
nativeVersion := firstNonEmpty(state.Get("frame_version"), cfg.AppVersion)
|
||||
authServer := state.AuthServer()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"authServer": authServer,
|
||||
"guid": guid,
|
||||
"nativeVersion": nativeVersion,
|
||||
"frameConfig": map[string]interface{}{},
|
||||
"basePath": cfg.BasePath,
|
||||
}
|
||||
|
||||
bootstrapJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm static] Failed to marshal bootstrap payload: %v", err)
|
||||
|
||||
bootstrapJSON = []byte("{}")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<script>
|
||||
(function () {
|
||||
window.StockholmBrowserBootstrap = %s;
|
||||
// __stockholmBase lets the bridge JS files resolve API URLs when Stockholm
|
||||
// is mounted under a prefix such as /stockholm.
|
||||
window.__stockholmBase = window.StockholmBrowserBootstrap.basePath || "";
|
||||
var bootstrap = window.StockholmBrowserBootstrap || {};
|
||||
|
||||
function toBase64(value) {
|
||||
return window.btoa(unescape(encodeURIComponent(String(value))));
|
||||
}
|
||||
|
||||
function mergeFrameConfig(config) {
|
||||
if (!bootstrap.frameConfig || typeof bootstrap.frameConfig !== "object") {
|
||||
return config;
|
||||
}
|
||||
config = config || {};
|
||||
config.default = config.default || {};
|
||||
Object.keys(bootstrap.frameConfig).forEach(function (key) {
|
||||
var value = bootstrap.frameConfig[key];
|
||||
if (!/^f\d+$/.test(key) || value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
var targetKey = "d" + key.substring(1);
|
||||
if (config.default[targetKey] === undefined || config.default[targetKey] === null
|
||||
|| config.default[targetKey] === "") {
|
||||
config.default[targetKey] = toBase64(value);
|
||||
}
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
var originalGetURLParams = window.getURLParams;
|
||||
if (typeof originalGetURLParams === "function") {
|
||||
window.getURLParams = function (name, url) {
|
||||
var value = originalGetURLParams(name, url);
|
||||
if (value !== null && value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
if (name === "native_version" && bootstrap.nativeVersion) {
|
||||
return bootstrap.nativeVersion;
|
||||
}
|
||||
if (name === "authServer" && bootstrap.authServer !== undefined && bootstrap.authServer !== null) {
|
||||
return String(bootstrap.authServer);
|
||||
}
|
||||
if (name === "guid" && bootstrap.guid) {
|
||||
return bootstrap.guid;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
var originalGetUserAgentValue = window.getUserAgentValue;
|
||||
if (typeof originalGetUserAgentValue === "function") {
|
||||
window.getUserAgentValue = function (name) {
|
||||
var value = originalGetUserAgentValue(name);
|
||||
if ((!value || value === "") && name === "_app" && bootstrap.guid) {
|
||||
return bootstrap.guid;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
if ((!window.guid || window.guid === "") && bootstrap.guid) {
|
||||
window.guid = bootstrap.guid;
|
||||
}
|
||||
if ((!window.frame_version || window.frame_version === "") && bootstrap.nativeVersion) {
|
||||
window.frame_version = bootstrap.nativeVersion;
|
||||
}
|
||||
if ((window.auth_server === undefined || window.auth_server === null || window.auth_server === "")
|
||||
&& bootstrap.authServer !== undefined && bootstrap.authServer !== null) {
|
||||
window.auth_server = bootstrap.authServer;
|
||||
}
|
||||
|
||||
var originalSettingsLoad = window.settingsLoad;
|
||||
if (typeof originalSettingsLoad === "function") {
|
||||
window.settingsLoad = function (config) {
|
||||
return originalSettingsLoad(mergeFrameConfig(config));
|
||||
};
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
`, string(bootstrapJSON))
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- injectBootstrap ----
|
||||
|
||||
func TestInjectBootstrap_InjectsBeforeHead(t *testing.T) {
|
||||
html := []byte(`<html><head><title>T</title></head><body></body></html>`)
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{AppVersion: "27.0"}
|
||||
|
||||
got := string(injectBootstrap(html, state, cfg))
|
||||
|
||||
if !strings.Contains(got, "window.StockholmBrowserBootstrap") {
|
||||
t.Error("expected bootstrap script to be injected")
|
||||
}
|
||||
|
||||
scriptEnd := strings.Index(got, "</script>")
|
||||
headEnd := strings.Index(got, "</head>")
|
||||
|
||||
if scriptEnd < 0 || headEnd < 0 || scriptEnd > headEnd {
|
||||
t.Error("expected bootstrap script to appear before </head>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectBootstrap_Idempotent(t *testing.T) {
|
||||
html := []byte(`<html><head><script>window.StockholmBrowserBootstrap = {};</script></head><body></body></html>`)
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
|
||||
got := injectBootstrap(html, state, cfg)
|
||||
|
||||
if strings.Count(string(got), "StockholmBrowserBootstrap") != 1 {
|
||||
t.Error("expected bootstrap not to be injected a second time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectBootstrap_NoHeadTag_ReturnsUnchanged(t *testing.T) {
|
||||
html := []byte(`<html><body>no head here</body></html>`)
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
|
||||
got := injectBootstrap(html, state, cfg)
|
||||
|
||||
if string(got) != string(html) {
|
||||
t.Error("expected html to be returned unchanged when </head> is absent")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- isBootstrapTarget ----
|
||||
|
||||
func TestIsBootstrapTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"index.html", true},
|
||||
{"INDEX.HTML", true},
|
||||
{"setup/index.html", true},
|
||||
{"SETUP/INDEX.HTML", true},
|
||||
{"js/app.js", false},
|
||||
{"css/main.css", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := isBootstrapTarget(tc.path); got != tc.want {
|
||||
t.Errorf("isBootstrapTarget(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- resolveStaticFile ----
|
||||
|
||||
func TestResolveStaticFile_Normal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "app.js"), []byte("js"), 0644)
|
||||
|
||||
file, rel, err := resolveStaticFile("/app.js", dir)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file, "app.js") {
|
||||
t.Errorf("expected file path to end with app.js, got %q", file)
|
||||
}
|
||||
|
||||
if rel != "app.js" {
|
||||
t.Errorf("expected rel = %q, got %q", "app.js", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStaticFile_RootMapsToIndexHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html>"), 0644)
|
||||
|
||||
file, rel, err := resolveStaticFile("/", dir)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file, "index.html") {
|
||||
t.Errorf("expected file path to end with index.html, got %q", file)
|
||||
}
|
||||
|
||||
if rel != "index.html" {
|
||||
t.Errorf("expected rel = %q, got %q", "index.html", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStaticFile_DirectoryMapsToIndexHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
subDir := filepath.Join(dir, "setup")
|
||||
_ = os.MkdirAll(subDir, 0755)
|
||||
_ = os.WriteFile(filepath.Join(subDir, "index.html"), []byte("<html>"), 0644)
|
||||
|
||||
file, rel, err := resolveStaticFile("/setup", dir)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file, filepath.Join("setup", "index.html")) {
|
||||
t.Errorf("expected file path to end with setup/index.html, got %q", file)
|
||||
}
|
||||
|
||||
if rel != "setup/index.html" {
|
||||
t.Errorf("expected rel = %q, got %q", "setup/index.html", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStaticFile_PathTraversalRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
_, _, err := resolveStaticFile("/../../../etc/passwd", dir)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ServeStatic integration ----
|
||||
|
||||
func TestServeStatic_ServesHTMLWithBootstrap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "index.html"),
|
||||
[]byte(`<html><head></head><body></body></html>`), 0644)
|
||||
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{AppVersion: "27.0"}
|
||||
backendCfg := &BackendConfig{}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ServeStatic(rec, req, dir, backendCfg, state, cfg)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "StockholmBrowserBootstrap") {
|
||||
t.Error("expected bootstrap to be injected in served HTML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStatic_NotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
backendCfg := &BackendConfig{}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/nonexistent.js", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ServeStatic(rec, req, dir, backendCfg, state, cfg)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStatic_HeadReturnsNoBody(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "index.html"),
|
||||
[]byte(`<html><head></head><body></body></html>`), 0644)
|
||||
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
backendCfg := &BackendConfig{}
|
||||
|
||||
req := httptest.NewRequest(http.MethodHead, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ServeStatic(rec, req, dir, backendCfg, state, cfg)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body for HEAD, got %d bytes", rec.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStatic_FrontendLoggingCookieSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "index.html"),
|
||||
[]byte(`<html><head></head><body></body></html>`), 0644)
|
||||
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
backendCfg := &BackendConfig{FrontendLoggingLevel: 2}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ServeStatic(rec, req, dir, backendCfg, state, cfg)
|
||||
|
||||
cookie := rec.Header().Get("Set-Cookie")
|
||||
if !strings.Contains(cookie, "stockholmFrontendLoggingLevel=2") {
|
||||
t.Errorf("expected logging cookie with level 2, got %q", cookie)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// kiloDefaultValue is the published default for the Stockholm "kilo"
|
||||
// constant, carried over from the upstream krahl/soundcork-stockholm-app
|
||||
// project (BackendApplication.java). Not a secret — this is the exact
|
||||
// value the Stockholm JS expects to read via getConstant("kilo") when
|
||||
// nothing else has stored a different one. Seeded into NativeState on
|
||||
// first run; also returned by the bridge as a fallback if the state
|
||||
// entry is missing.
|
||||
const kiloDefaultValue = "a7928d7b43dcd49f0af31e5aeed26458"
|
||||
|
||||
func randomHexUUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch Stockholm bridge JS files to use window.__stockholmBase for API paths.
|
||||
|
||||
Usage: patch-stockholm-bridge.py <file> [<file> ...]
|
||||
|
||||
Applied once by `make prepare-stockholm`. Idempotent — already-patched files
|
||||
are left unchanged.
|
||||
"""
|
||||
import sys
|
||||
|
||||
REPLACEMENTS = [
|
||||
(
|
||||
'xhr.open("POST", "/api/native/appSend"',
|
||||
'xhr.open("POST", (window.__stockholmBase||"") + "/api/native/appSend"',
|
||||
),
|
||||
(
|
||||
'xhr.open("GET", "/api/native/runQueue',
|
||||
'xhr.open("GET", (window.__stockholmBase||"") + "/api/native/runQueue',
|
||||
),
|
||||
(
|
||||
'var proxyPath = "/api/http-proxy";',
|
||||
'var proxyPath = (window.__stockholmBase||"") + "/api/http-proxy";',
|
||||
),
|
||||
(
|
||||
# The standalone /api/http-proxy declaration in browser_http_proxy.js
|
||||
# uses an UPPERCASE constant name. Same shape as above, different
|
||||
# identifier — keep both replacements; the lowercase one applies to
|
||||
# app_comm.js, the uppercase one to browser_http_proxy.js.
|
||||
'var PROXY_PATH = "/api/http-proxy";',
|
||||
'var PROXY_PATH = (window.__stockholmBase||"") + "/api/http-proxy";',
|
||||
),
|
||||
(
|
||||
# browser_http_proxy.js's IIFE evaluates PROXY_PATH at script-load
|
||||
# time, but the injected bootstrap that defines window.__stockholmBase
|
||||
# is placed just before </head> — i.e. after the <script src=…> tags
|
||||
# for the bridge files. So PROXY_PATH would always fall back to the
|
||||
# unprefixed "/api/http-proxy", failing under STOCKHOLM_BASE_PATH.
|
||||
# Inline a lazy expression at the use site so it reads __stockholmBase
|
||||
# at call time, when bootstrap has finished. The var declaration above
|
||||
# remains patched but becomes dead code.
|
||||
'return PROXY_PATH + "?url=" + encodeURIComponent(target.href);',
|
||||
'return (window.__stockholmBase||"") + "/api/http-proxy?url=" + encodeURIComponent(target.href);',
|
||||
),
|
||||
(
|
||||
'return new URL(url, window.location.origin + "/").href;',
|
||||
'return new URL(url, window.location.origin + (window.__stockholmBase || "") + "/").href;',
|
||||
),
|
||||
]
|
||||
|
||||
for path in sys.argv[1:]:
|
||||
try:
|
||||
original = open(path).read()
|
||||
patched = original
|
||||
for old, new in REPLACEMENTS:
|
||||
patched = patched.replace(old, new)
|
||||
if patched != original:
|
||||
open(path, "w").write(patched)
|
||||
except FileNotFoundError:
|
||||
print(f"warning: {path} not found, skipping", file=sys.stderr)
|
||||
@@ -1,4 +1,8 @@
|
||||
### GET /bmx/tunein/v1/playback/station/_station_
|
||||
### GET /bmx/tunein/v1/playback/station/_station_ (no Authorization)
|
||||
### Auth gate temporarily disabled — see handlers_bmx.go (writeBMXUnauthorized
|
||||
### is kept as the future-restore point). When the gate is re-enabled,
|
||||
### swap the 200/audio assertions below back to the 401/Unauthorized ones
|
||||
### that were here historically.
|
||||
GET {{host}}/bmx/tunein/v1/playback/station/_station_
|
||||
Accept: */*
|
||||
Accept-Language: en
|
||||
@@ -7,13 +11,13 @@ X-Bmx-Device-Id: bmx-device-id-dummy
|
||||
User-Agent: Bose_Lisa/27.0.6
|
||||
|
||||
> {%
|
||||
client.test("Response is 401 Unauthorized", function() {
|
||||
client.assert(response.status === 401, "Response status is not 401");
|
||||
client.test("Response is 200 OK (auth gate temporarily disabled)", function() {
|
||||
client.assert(response.status === 200, "Response status is not 200");
|
||||
});
|
||||
|
||||
client.test("Response body contains 'Unauthorized'", function() {
|
||||
client.assert(response.body.includes("401 Unauthorized"), "Response body does not contain '401 Unauthorized'");
|
||||
client.assert(response.body.includes("No access token found."), "Response body does not contain 'No access token found.'");
|
||||
client.test("Response contains audio information", function() {
|
||||
client.assert(response.body.hasOwnProperty("audio"), "Response missing 'audio'");
|
||||
client.assert(response.body.audio.hasOwnProperty("streamUrl"), "Response missing 'streamUrl'");
|
||||
});
|
||||
%}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user