Files
memby/server/internal/appupdate/policy.go
T
2026-07-27 21:06:51 +12:00

124 lines
3.8 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
}
// CompareVersions returns -1, 0, or 1 and is shared by policy decisions and the release
// publisher's downgrade guard.
func CompareVersions(a, b string) int { return compare(parseVersion(a), parseVersion(b)) }
// 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") }