// 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"` // RetireBelowVersion is the destructive floor. Clients below it have their session // removed before receiving the mandatory update screen. It is separate from // MinimumVersion so an ordinary required update does not sign viewers out. RetireBelowVersion string `json:"retireBelowVersion,omitempty"` // DownloadURL points at the APK — normally the same file the landing page serves. DownloadURL string `json:"downloadUrl"` // SHA256 and SizeBytes let the TV reject a truncated, stale or substituted download // before handing it to Android's package installer. Blank/zero remain valid for // older policies entered manually through the admin page. SHA256 string `json:"sha256,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"` 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"` SHA256 string `json:"sha256,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"` } // 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), SHA256: strings.ToLower(strings.TrimSpace(policy.SHA256)), SizeBytes: policy.SizeBytes, } 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") }