mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
feat(stockholm): add Go backend integration for Stockholm frontend
Implements pkg/service/stockholm with bridge (appSend/runQueue), HTTP proxy, static serving, config URL rewriting, native state persistence, and device discovery. Mounts under a configurable base path (/stockholm by default) with correct http.StripPrefix routing and apiBase-prefixed bridge API routes matching the patched JS window.__stockholmBase calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
c64e601df6
commit
6fb999435a
@@ -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
|
||||
|
||||
@@ -102,5 +102,7 @@ pids
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
.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
|
||||
GOBUILD=$(GOCMD) build
|
||||
@@ -33,10 +36,19 @@ 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
|
||||
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
|
||||
|
||||
@@ -357,11 +369,42 @@ prepare-stockholm:
|
||||
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.
|
||||
@# Also fix resolveWebviewUrl to include the base path when resolving relative URLs.
|
||||
@python3 scripts/patch-stockholm-bridge.py \
|
||||
"$(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:
|
||||
|
||||
@@ -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 —
|
||||
@@ -1140,8 +1171,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 {
|
||||
|
||||
@@ -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,53 @@ 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
|
||||
# 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,647 @@
|
||||
# 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 current patches are v1 (1 153 lines) and v2 (1 475 lines).
|
||||
|
||||
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"
|
||||
patch -p1 --batch < stockholm-changes_v1.patch
|
||||
patch -p1 --batch < stockholm-changes_v2.patch
|
||||
```
|
||||
|
||||
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
|
||||
@@ -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 = "a7928d7b43dcd49f0af31e5aeed26458"
|
||||
}
|
||||
|
||||
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,140 @@
|
||||
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, "/")
|
||||
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,229 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Default constant
|
||||
if s.Get("constant.kilo") == "" {
|
||||
updates["constant.kilo"] = "a7928d7b43dcd49f0af31e5aeed26458"
|
||||
}
|
||||
|
||||
// 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,13 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func randomHexUUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/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";',
|
||||
),
|
||||
(
|
||||
'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)
|
||||
Reference in New Issue
Block a user