Server-controlled app updates, optional or forced
The gateway decides whether a TV may keep running its current build. Clients send X-Memby-Version on every request and ask GET /v1/update on each launch; the verdict is none, optional or mandatory. - internal/appupdate holds the decision as pure, tested logic: below minimumVersion is mandatory, below latestVersion is optional. - Admin page gains an App updates section — latest version, APK URL, notes, and a "Require this update" toggle that sets the forced floor. - Client shows a dismissable prompt for optional, and a full-screen panel that swallows Back for mandatory. Instructions say what the system installer will ask before it asks. Two safeguards: a client that cannot report its version is never forced, and the client ignores a verdict with no download URL, so a half-configured policy cannot produce an unblockable screen with a dead button. An unreachable gateway shows nothing. The verdict is deliberately not part of /v1/home: that payload is cached per user, while this answer depends on the requesting client's version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
08360b75e4
commit
62f6345a40
@@ -0,0 +1,119 @@
|
||||
// Package appupdate decides whether a TV should be told to update, and how firmly.
|
||||
//
|
||||
// The gateway is the authority: the client reports the version it is running and renders
|
||||
// whatever verdict comes back. That keeps "everyone must move to 0.1.54 now" a one-field
|
||||
// change on the admin page rather than a release.
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status is how hard the TV should push the update.
|
||||
const (
|
||||
// StatusNone — nothing to say; the client is current enough.
|
||||
StatusNone = "none"
|
||||
// StatusOptional — a newer build exists and the viewer may dismiss the prompt.
|
||||
StatusOptional = "optional"
|
||||
// StatusMandatory — the client is below the minimum supported version and must
|
||||
// update before it can be used.
|
||||
StatusMandatory = "mandatory"
|
||||
)
|
||||
|
||||
// Policy is the operator-controlled setting, stored in app_settings.
|
||||
type Policy struct {
|
||||
// Enabled turns the whole mechanism off without losing the values.
|
||||
Enabled bool `json:"enabled"`
|
||||
// LatestVersion is what clients below it are *offered*.
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
// MinimumVersion is what clients below it are *forced* to. Leave blank (or equal to
|
||||
// an old release) to keep updates optional; set it to LatestVersion to make the
|
||||
// current release mandatory for everyone.
|
||||
MinimumVersion string `json:"minimumVersion"`
|
||||
// DownloadURL points at the APK — normally the same file the landing page serves.
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
Notes string `json:"notes"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Decision is what the client receives.
|
||||
type Decision struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Notes string `json:"notes"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
}
|
||||
|
||||
// Decide compares the version a client reported against the policy.
|
||||
//
|
||||
// An unreadable or absent client version yields StatusNone on purpose: a client that
|
||||
// cannot say what it is would otherwise be locked out by a blocking prompt it may not
|
||||
// even know how to satisfy. Those builds still have the manual check in Settings.
|
||||
func Decide(policy Policy, clientVersion string) Decision {
|
||||
none := Decision{Status: StatusNone}
|
||||
|
||||
if !policy.Enabled || strings.TrimSpace(policy.LatestVersion) == "" {
|
||||
return none
|
||||
}
|
||||
client := parseVersion(clientVersion)
|
||||
if len(client) == 0 {
|
||||
return none
|
||||
}
|
||||
|
||||
offer := Decision{
|
||||
Status: StatusOptional,
|
||||
Version: normalize(policy.LatestVersion),
|
||||
Notes: strings.TrimSpace(policy.Notes),
|
||||
DownloadURL: strings.TrimSpace(policy.DownloadURL),
|
||||
}
|
||||
|
||||
if minimum := parseVersion(policy.MinimumVersion); len(minimum) > 0 && compare(client, minimum) < 0 {
|
||||
offer.Status = StatusMandatory
|
||||
return offer
|
||||
}
|
||||
if compare(client, parseVersion(policy.LatestVersion)) < 0 {
|
||||
return offer
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// compare returns -1, 0 or 1. Missing components count as zero, so 0.1 == 0.1.0.
|
||||
func compare(a, b []int) int {
|
||||
for i := 0; i < len(a) || i < len(b); i++ {
|
||||
av, bv := 0, 0
|
||||
if i < len(a) {
|
||||
av = a[i]
|
||||
}
|
||||
if i < len(b) {
|
||||
bv = b[i]
|
||||
}
|
||||
if av != bv {
|
||||
if av < bv {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseVersion mirrors the client's own parsing, so both sides agree on ordering.
|
||||
func parseVersion(v string) []int {
|
||||
parts := strings.FieldsFunc(normalize(v), func(r rune) bool {
|
||||
return r == '.' || r == '-' || r == '+' || r == ' '
|
||||
})
|
||||
out := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
// Stop at the first non-numeric component ("0.1.54-beta" -> 0.1.54).
|
||||
break
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalize(v string) string { return strings.TrimLeft(strings.TrimSpace(v), "vV") }
|
||||
@@ -0,0 +1,118 @@
|
||||
package appupdate
|
||||
|
||||
import "testing"
|
||||
|
||||
func policy() Policy {
|
||||
return Policy{
|
||||
Enabled: true,
|
||||
LatestVersion: "0.1.54",
|
||||
MinimumVersion: "0.1.50",
|
||||
DownloadURL: "https://nas.example.com/memby/memby-0.1.54.apk",
|
||||
Notes: "Faster home screen",
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpToDateClientIsLeftAlone(t *testing.T) {
|
||||
for _, version := range []string{"0.1.54", "0.1.55", "0.2.0", "1.0.0"} {
|
||||
if got := Decide(policy(), version).Status; got != StatusNone {
|
||||
t.Fatalf("client %s: got %s, want none", version, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehindLatestIsOptional(t *testing.T) {
|
||||
decision := Decide(policy(), "0.1.53")
|
||||
|
||||
if decision.Status != StatusOptional {
|
||||
t.Fatalf("got %s, want optional", decision.Status)
|
||||
}
|
||||
if decision.Version != "0.1.54" || decision.DownloadURL == "" {
|
||||
t.Fatalf("decision is missing what the TV needs to act: %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBelowMinimumIsMandatory(t *testing.T) {
|
||||
if got := Decide(policy(), "0.1.49").Status; got != StatusMandatory {
|
||||
t.Fatalf("got %s, want mandatory", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Setting minimum = latest is how an operator forces everyone onto the current build.
|
||||
func TestMinimumEqualToLatestForcesEveryOldClient(t *testing.T) {
|
||||
p := policy()
|
||||
p.MinimumVersion = p.LatestVersion
|
||||
|
||||
if got := Decide(p, "0.1.53").Status; got != StatusMandatory {
|
||||
t.Fatalf("got %s, want mandatory", got)
|
||||
}
|
||||
if got := Decide(p, "0.1.54").Status; got != StatusNone {
|
||||
t.Fatalf("a current client should still be left alone, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlankMinimumKeepsUpdatesOptional(t *testing.T) {
|
||||
p := policy()
|
||||
p.MinimumVersion = ""
|
||||
|
||||
if got := Decide(p, "0.0.1").Status; got != StatusOptional {
|
||||
t.Fatalf("got %s, want optional", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledPolicySaysNothing(t *testing.T) {
|
||||
p := policy()
|
||||
p.Enabled = false
|
||||
|
||||
if got := Decide(p, "0.0.1").Status; got != StatusNone {
|
||||
t.Fatalf("got %s, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyWithNoLatestVersionSaysNothing(t *testing.T) {
|
||||
p := policy()
|
||||
p.LatestVersion = " "
|
||||
|
||||
if got := Decide(p, "0.0.1").Status; got != StatusNone {
|
||||
t.Fatalf("got %s, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A client that cannot report its version must not be hard-blocked: it would be stuck
|
||||
// behind a prompt it may have no way to satisfy.
|
||||
func TestUnknownClientVersionIsNeverForced(t *testing.T) {
|
||||
p := policy()
|
||||
p.MinimumVersion = p.LatestVersion
|
||||
|
||||
for _, version := range []string{"", " ", "?", "unknown"} {
|
||||
if got := Decide(p, version).Status; got != StatusNone {
|
||||
t.Fatalf("client %q: got %s, want none", version, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionParsingIsForgiving(t *testing.T) {
|
||||
p := policy()
|
||||
|
||||
// Leading "v", pre-release suffixes and short versions all compare sensibly.
|
||||
if got := Decide(p, "v0.1.53").Status; got != StatusOptional {
|
||||
t.Fatalf("v-prefixed: got %s, want optional", got)
|
||||
}
|
||||
if got := Decide(p, "0.1.54-beta").Status; got != StatusNone {
|
||||
t.Fatalf("pre-release suffix should compare as 0.1.54, got %s", got)
|
||||
}
|
||||
|
||||
p.LatestVersion = "0.2"
|
||||
if got := Decide(p, "0.2.0").Status; got != StatusNone {
|
||||
t.Fatalf("0.2.0 should equal 0.2, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMandatoryOutranksOptional(t *testing.T) {
|
||||
p := policy()
|
||||
p.MinimumVersion = "0.1.53"
|
||||
|
||||
// Below both thresholds: the stronger verdict must win.
|
||||
if got := Decide(p, "0.1.52").Status; got != StatusMandatory {
|
||||
t.Fatalf("got %s, want mandatory", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user