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>
120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
// 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") }
|