Files
memby/server/internal/config/remote_config.go
T

170 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
// RemoteConfig is the deliberately small, presentation-only document offered to TVs.
// It must never contain authentication, playback or navigation-routing decisions: an
// unavailable document is required to be indistinguishable from an ordinary offline
// launch apart from its wording and safe presentation choices.
type RemoteConfig struct {
SchemaVersion int `json:"schemaVersion"`
ConfigVersion int64 `json:"configVersion"`
MinimumAppVersion string `json:"minimumAppVersion,omitempty"`
MaximumAppVersion string `json:"maximumAppVersion,omitempty"`
Copy RemoteConfigCopy `json:"copy"`
Features RemoteConfigFeatures `json:"features"`
Presentation RemoteConfigPresentation `json:"presentation"`
}
type RemoteConfigCopy struct {
Navigation RemoteConfigNavigationCopy `json:"navigation"`
Tagline string `json:"tagline"`
}
type RemoteConfigNavigationCopy struct {
Home string `json:"home"`
ForYou string `json:"forYou"`
Search string `json:"search"`
Movies string `json:"movies"`
TVShows string `json:"tvShows"`
TVCalendar string `json:"tvCalendar"`
Favourites string `json:"favourites"`
User string `json:"user"`
Settings string `json:"settings"`
}
type RemoteConfigFeatures struct {
ShowNavigationVersion bool `json:"showNavigationVersion"`
}
type RemoteConfigPresentation struct {
NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"`
NavigationContentShiftDp int `json:"navigationContentShiftDp"`
}
// DefaultRemoteConfig mirrors the APK's bundled values. Serving it is still useful: it
// establishes the schema and ETag contract before an operator chooses an override.
func DefaultRemoteConfig() RemoteConfig {
return RemoteConfig{
SchemaVersion: 1,
ConfigVersion: 1,
Copy: RemoteConfigCopy{
Tagline: "Matts Android TV client",
Navigation: RemoteConfigNavigationCopy{
Home: "Home", ForYou: "For You", Search: "Search", Movies: "Movies",
TVShows: "TV Shows", TVCalendar: "TV Calendar", Favourites: "Favourites",
User: "User", Settings: "Settings",
},
},
Features: RemoteConfigFeatures{ShowNavigationVersion: true},
Presentation: RemoteConfigPresentation{
NavigationRailExpandedWidthDp: 184,
NavigationContentShiftDp: 112,
},
}
}
func loadRemoteConfig(raw string) (RemoteConfig, error) {
if strings.TrimSpace(raw) == "" {
return DefaultRemoteConfig(), nil
}
var document RemoteConfig
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil {
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON must contain exactly one document")
}
if err := validateRemoteConfig(document); err != nil {
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err)
}
return document, nil
}
func validateRemoteConfig(document RemoteConfig) error {
if document.SchemaVersion != 1 {
return fmt.Errorf("schemaVersion must be 1")
}
if document.ConfigVersion < 1 {
return fmt.Errorf("configVersion must be positive")
}
minimum, minimumSet, err := parseRemoteConfigVersion(document.MinimumAppVersion)
if err != nil {
return fmt.Errorf("minimumAppVersion must be a three-part version")
}
maximum, maximumSet, err := parseRemoteConfigVersion(document.MaximumAppVersion)
if err != nil {
return fmt.Errorf("maximumAppVersion must be a three-part version")
}
if minimumSet && maximumSet && compareRemoteConfigVersions(minimum, maximum) > 0 {
return fmt.Errorf("minimumAppVersion must not be newer than maximumAppVersion")
}
labels := []string{
document.Copy.Tagline,
document.Copy.Navigation.Home,
document.Copy.Navigation.ForYou,
document.Copy.Navigation.Search,
document.Copy.Navigation.Movies,
document.Copy.Navigation.TVShows,
document.Copy.Navigation.TVCalendar,
document.Copy.Navigation.Favourites,
document.Copy.Navigation.User,
document.Copy.Navigation.Settings,
}
for _, label := range labels {
trimmed := strings.TrimSpace(label)
if trimmed == "" || len([]rune(trimmed)) > 64 || strings.ContainsAny(trimmed, "\r\n\t") {
return fmt.Errorf("copy must be between 1 and 64 characters and contain no control whitespace")
}
}
width := document.Presentation.NavigationRailExpandedWidthDp
shift := document.Presentation.NavigationContentShiftDp
if width < 160 || width > 240 {
return fmt.Errorf("navigationRailExpandedWidthDp must be between 160 and 240")
}
if shift < 80 || shift > 160 || shift >= width {
return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width")
}
return nil
}
func parseRemoteConfigVersion(raw string) ([3]int, bool, error) {
var version [3]int
if strings.TrimSpace(raw) == "" {
return version, false, nil
}
parts := strings.Split(raw, ".")
if len(parts) != len(version) {
return version, false, fmt.Errorf("invalid version")
}
for index, part := range parts {
value, err := strconv.Atoi(part)
if err != nil || value < 0 {
return version, false, fmt.Errorf("invalid version")
}
version[index] = value
}
return version, true, nil
}
func compareRemoteConfigVersions(left, right [3]int) int {
for index := range left {
if left[index] < right[index] {
return -1
}
if left[index] > right[index] {
return 1
}
}
return 0
}