0.2.45 - Server side git commits

This commit is contained in:
ponzischeme89
2026-08-10 20:54:00 +12:00
parent 4c47a5f8a0
commit dd2dd497f2
11 changed files with 170 additions and 63 deletions
-3
View File
@@ -1,9 +1,6 @@
## 0.2.45 - 2026-08-10 ## 0.2.45 - 2026-08-10
- Bug fixes - Bug fixes
## Server 0.1.31 — 2026-08-10
- Added: The admin console now has a Journeys page for visit health, feature use, significant actions, common paths and per-profile event history.
## 0.2.43 — 2026-08-10 ## 0.2.43 — 2026-08-10
- Fixed: App no longer crashes. - Fixed: App no longer crashes.
- Fixed: Manual surround-sound choices now reliably override the automatically detected audio output. - Fixed: Manual surround-sound choices now reliably override the automatically detected audio output.
+15 -8
View File
@@ -424,6 +424,13 @@ while left open; the verdict is `none`, `optional` or `mandatory`. This endpoint
deliberately public and remains available during maintenance: update policy is checked deliberately public and remains available during maintenance: update policy is checked
before login and never reads, validates, or mutates a viewer session. before login and never reads, validates, or mutates a viewer session.
The App updates page separates a required update from a destructive one. Required covers
the home screen until the APK is installed but keeps the viewer's session. “Set the
destructive floor to this update” records the release as the destructive floor: the next request
from an older build deletes its session, and signing in again is refused until that build
has updated. “Sign out builds below” lets the operator set that floor to an exact version
instead; leaving it blank disables destructive retirement.
### First-time TV installation ### First-time TV installation
The gateway hosts a public bootstrap page at: The gateway hosts a public bootstrap page at:
@@ -522,7 +529,8 @@ For a server-only emergency deployment, explicitly opt out:
``` ```
Set it on the admin page: **latest version**, **APK URL** (normally the same file the Set it on the admin page: **latest version**, **APK URL** (normally the same file the
landing page serves), release notes, and a **Require this update** toggle. landing page serves), release notes, the required-update toggle, and an optional
destructive compatibility floor.
- *Optional* — a dismissable prompt. Dismissal lasts for that session only. - *Optional* — a dismissable prompt. Dismissal lasts for that session only.
- *Required* — a full-screen panel over the home screen with no way past it. Back is - *Required* — a full-screen panel over the home screen with no way past it. Back is
@@ -533,13 +541,12 @@ landing page serves), release notes, and a **Require this update** toggle.
`minimumVersion` can also be set directly for a staged rollout where the forced floor is `minimumVersion` can also be set directly for a staged rollout where the forced floor is
older than the newest build. older than the newest build.
Builds below 0.2.44 are permanently retired once the enabled policy points at an When **Sign out builds below** is set, a build below that version is retired on its next
actionable 0.2.44-or-newer release. On their next authenticated request the gateway authenticated request: the gateway deletes the session and returns 401, which makes the TV
deletes the session and returns 401, which makes the TV remove the rejected local profile; remove the rejected local profile. The public update check continues to return the
the public update check continues to return the mandatory update screen. The gateway also mandatory update screen, and the gateway refuses a new login from the retired build, so
refuses a new login from a retired build, so signing in again cannot bypass the update. signing in again cannot bypass the update. The floor remains dormant when the policy has no
This floor remains dormant when the policy has no download URL or its latest release is download URL or its latest release is older than the selected floor.
older than 0.2.44.
Two deliberate safeguards, both tested in `internal/appupdate`: Two deliberate safeguards, both tested in `internal/appupdate`:
+27 -7
View File
@@ -521,6 +521,12 @@ type updatePolicyRequest struct {
// toggle rather than exposing "minimum version" directly, because "force this // toggle rather than exposing "minimum version" directly, because "force this
// update" is the decision an operator actually wants to make. // update" is the decision an operator actually wants to make.
Required bool `json:"required"` Required bool `json:"required"`
// Destructive removes sessions for clients below this release. It implies Required,
// but remains separate so a required update can keep viewers signed in.
Destructive bool `json:"destructive"`
// RetireBelowVersion exposes the exact destructive compatibility floor for releases
// where the operator needs to retire only part of the installed fleet.
RetireBelowVersion string `json:"retireBelowVersion"`
// MinimumVersion is honoured when set explicitly, for staged rollouts where the // MinimumVersion is honoured when set explicitly, for staged rollouts where the
// forced floor is older than the latest build. // forced floor is older than the latest build.
MinimumVersion string `json:"minimumVersion"` MinimumVersion string `json:"minimumVersion"`
@@ -533,14 +539,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
return return
} }
policy := appupdate.Policy{
Enabled: req.Enabled,
LatestVersion: strings.TrimSpace(req.LatestVersion),
MinimumVersion: strings.TrimSpace(req.MinimumVersion),
DownloadURL: strings.TrimSpace(req.DownloadURL),
Notes: strings.TrimSpace(req.Notes),
}
current := s.updatePolicy.get() current := s.updatePolicy.get()
policy := appupdate.Policy{
Enabled: req.Enabled,
LatestVersion: strings.TrimSpace(req.LatestVersion),
MinimumVersion: strings.TrimSpace(req.MinimumVersion),
RetireBelowVersion: strings.TrimSpace(req.RetireBelowVersion),
DownloadURL: strings.TrimSpace(req.DownloadURL),
Notes: strings.TrimSpace(req.Notes),
}
if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL { if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL {
// Changing "required" or release notes must not silently discard integrity // Changing "required" or release notes must not silently discard integrity
// metadata added by the signed release publisher. // metadata added by the signed release publisher.
@@ -554,6 +561,10 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
// Un-ticking the box must actually release the floor. // Un-ticking the box must actually release the floor.
policy.MinimumVersion = "" policy.MinimumVersion = ""
} }
if req.Destructive {
policy.MinimumVersion = policy.LatestVersion
policy.RetireBelowVersion = policy.LatestVersion
}
if policy.Enabled && policy.LatestVersion == "" { if policy.Enabled && policy.LatestVersion == "" {
writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts") writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts")
@@ -563,6 +574,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts") writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts")
return return
} }
if policy.RetireBelowVersion != "" && !releaseVersionPattern.MatchString(policy.RetireBelowVersion) {
writeError(w, http.StatusBadRequest, "the destructive update floor must look like 0.2.44")
return
}
if policy.Enabled && policy.RetireBelowVersion != "" &&
appupdate.CompareVersions(policy.RetireBelowVersion, policy.LatestVersion) > 0 {
writeError(w, http.StatusBadRequest, "the destructive update floor cannot be newer than the latest version")
return
}
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil { if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
s.loggerFor(r.Context()).Error("update policy write failed", "error", err) s.loggerFor(r.Context()).Error("update policy write failed", "error", err)
@@ -17,11 +17,20 @@
<label class="field"><span>What's new</span> <label class="field"><span>What's new</span>
<em>Shown on the television above the update button.</em> <em>Shown on the television above the update button.</em>
<input type="text" id="update-notes" placeholder="One line the viewer reads"></label> <input type="text" id="update-notes" placeholder="One line the viewer reads"></label>
<label class="field"><span>Sign out builds below</span>
<em>The destructive compatibility floor. Leave blank to keep every supported viewer
signed in.</em>
<input type="text" id="update-retire-below" placeholder="0.2.44"></label>
<label class="check"> <label class="check">
<input type="checkbox" id="update-required"> <input type="checkbox" id="update-required">
<span>Require this update<em>Blocks the home screen on every television below this <span>Require this update<em>Blocks the home screen on every television below this
version.</em></span> version.</em></span>
</label> </label>
<label class="check">
<input type="checkbox" id="update-destructive">
<span>Set the destructive floor to this update<em>Deletes sessions on every older
television when it next uses Memby, then shows the required update screen.</em></span>
</label>
<div class="card-foot"> <div class="card-foot">
<button class="primary" id="update-save">Save policy</button> <button class="primary" id="update-save">Save policy</button>
<button id="update-disable">Turn prompts off</button> <button id="update-disable">Turn prompts off</button>
+21 -4
View File
@@ -5,16 +5,20 @@ Admin.onStatus((status) => {
// "Required" is not a field of its own: it is the minimum and the latest being the same // "Required" is not a field of its own: it is the minimum and the latest being the same
// version, which is what the client compares against. // version, which is what the client compares against.
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion; const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
const destructive = Boolean(policy.retireBelowVersion) &&
policy.retireBelowVersion === policy.latestVersion;
$('update-state').innerHTML = !policy.enabled $('update-state').innerHTML = !policy.enabled
? ui.tag('off', 'idle') ? ui.tag('off', 'idle')
: ui.tag((required ? 'required · ' : 'optional · ') + policy.latestVersion, : ui.tag((destructive ? 'sign-out · ' : required ? 'required · ' : 'optional · ') +
required ? 'warn' : 'ok'); policy.latestVersion, required ? 'warn' : 'ok');
Admin.fill($('update-version'), policy.latestVersion || ''); Admin.fill($('update-version'), policy.latestVersion || '');
Admin.fill($('update-url'), policy.downloadUrl || ''); Admin.fill($('update-url'), policy.downloadUrl || '');
Admin.fill($('update-notes'), policy.notes || ''); Admin.fill($('update-notes'), policy.notes || '');
Admin.fill($('update-retire-below'), policy.retireBelowVersion || '');
Admin.check($('update-required'), required); Admin.check($('update-required'), required);
Admin.check($('update-destructive'), destructive);
}); });
const body = (enabled) => JSON.stringify({ const body = (enabled) => JSON.stringify({
@@ -23,12 +27,25 @@ const body = (enabled) => JSON.stringify({
downloadUrl: $('update-url').value.trim(), downloadUrl: $('update-url').value.trim(),
notes: $('update-notes').value.trim(), notes: $('update-notes').value.trim(),
required: $('update-required').checked, required: $('update-required').checked,
destructive: $('update-destructive').checked,
retireBelowVersion: $('update-retire-below').value.trim(),
}); });
Admin.ready(() => { Admin.ready(() => {
$('update-destructive').addEventListener('change', () => {
if ($('update-destructive').checked) {
$('update-required').checked = true;
$('update-retire-below').value = $('update-version').value.trim();
} else if ($('update-retire-below').value.trim() === $('update-version').value.trim()) {
$('update-retire-below').value = '';
}
});
$('update-save').addEventListener('click', () => { $('update-save').addEventListener('click', () => {
if ($('update-required').checked && !confirm('Required updates block the home screen on ' + const destructive = $('update-destructive').checked;
'every television below this version. Continue?')) return; const warning = destructive
? 'This will delete sessions on every older television and force viewers to sign in again after updating. Continue?'
: 'Required updates block the home screen on every television below this version. Continue?';
if ($('update-required').checked && !confirm(warning)) return;
Admin.act(() => Admin.api('/admin/api/update-policy', { method: 'POST', body: body(true) })); Admin.act(() => Admin.api('/admin/api/update-policy', { method: 'POST', body: body(true) }));
}); });
$('update-disable').addEventListener('click', () => $('update-disable').addEventListener('click', () =>
+6 -3
View File
@@ -22,6 +22,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/bazarr" "github.com/ponzischeme89/memby/server/internal/bazarr"
"github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/config"
@@ -269,8 +270,10 @@ func (s *Server) authed(h authedFunc) http.Handler {
} }
sess = s.captureClientIdentity(r, sess) sess = s.captureClientIdentity(r, sess)
identify(r.Context(), sess) identify(r.Context(), sess)
decision := s.updateDecision(r) policy := s.updatePolicy.get()
if mustRetireForUpdate(decision, clientVersion(r)) { decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r))
retireBelow := destructiveUpdateFloor(policy)
if mustRetireForUpdate(decision, clientVersion(r), retireBelow) {
// Mirror an ordinary sign-out closely enough that this token cannot be restored // Mirror an ordinary sign-out closely enough that this token cannot be restored
// from either database or Redis. The 401 is intentional: every supported client // from either database or Redis. The 401 is intentional: every supported client
// treats it as authoritative and removes the rejected local profile. // treats it as authoritative and removes the rejected local profile.
@@ -282,7 +285,7 @@ func (s *Server) authed(h authedFunc) http.Handler {
s.loggerFor(r.Context()).Info("signed out for required update", s.loggerFor(r.Context()).Info("signed out for required update",
"device_id", sess.DeviceID, "device_id", sess.DeviceID,
"from", clientLogValue(clientVersion(r)), "from", clientLogValue(clientVersion(r)),
"minimum", forcedUpdateFloor, "minimum", retireBelow,
"to", decision.Version, "to", decision.Version,
) )
w.Header().Set("X-Memby-Update-Required", decision.Version) w.Header().Set("X-Memby-Update-Required", decision.Version)
+8 -7
View File
@@ -161,13 +161,14 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
} }
policy := appupdate.Policy{ policy := appupdate.Policy{
Enabled: true, Enabled: true,
LatestVersion: version, LatestVersion: version,
MinimumVersion: current.MinimumVersion, MinimumVersion: current.MinimumVersion,
DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename), RetireBelowVersion: current.RetireBelowVersion,
SHA256: actualSHA256, DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename),
SizeBytes: written, SHA256: actualSHA256,
Notes: strings.TrimSpace(r.FormValue("notes")), SizeBytes: written,
Notes: strings.TrimSpace(r.FormValue("notes")),
} }
if mandatory { if mandatory {
// Setting the floor to the release being published makes every older client // Setting the floor to the release being published makes every older client
+26 -17
View File
@@ -17,12 +17,6 @@ import (
// speaks, next to the build's own version. // speaks, next to the build's own version.
const ProtocolVersion = 1 const ProtocolVersion = 1
// forcedUpdateFloor retires builds whose update behaviour is no longer reliable enough
// to leave optional. The floor only takes effect once an enabled policy points at this
// version (or a newer one) and carries a download URL, so deploying the gateway before
// publishing the APK cannot lock televisions out.
const forcedUpdateFloor = "0.2.44"
// updatePolicyCache keeps the policy in memory. It is read on every home request, and a // updatePolicyCache keeps the policy in memory. It is read on every home request, and a
// database round trip per home load to answer "nothing to say" would be wasteful. // database round trip per home load to answer "nothing to say" would be wasteful.
type updatePolicyCache struct { type updatePolicyCache struct {
@@ -96,16 +90,30 @@ func compatibilityFor(r *http.Request) (bool, string) {
return true, "" return true, ""
} }
// effectiveUpdatePolicy applies the server-owned emergency floor without weakening a // destructiveUpdateFloor returns the operator-selected compatibility floor once an
// higher minimum the operator has already selected. // actionable release at or above it exists. This keeps a policy saved ahead of its APK
// from locking televisions out.
func destructiveUpdateFloor(policy appupdate.Policy) string {
if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" {
return ""
}
floor := strings.TrimSpace(policy.RetireBelowVersion)
if floor == "" || appupdate.CompareVersions(policy.LatestVersion, floor) < 0 {
return ""
}
return floor
}
// effectiveUpdatePolicy applies the destructive floor without weakening a higher
// non-destructive minimum the operator has already selected.
func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy { func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy {
if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" || floor := destructiveUpdateFloor(policy)
appupdate.CompareVersions(policy.LatestVersion, forcedUpdateFloor) < 0 { if floor == "" {
return policy return policy
} }
if strings.TrimSpace(policy.MinimumVersion) == "" || if strings.TrimSpace(policy.MinimumVersion) == "" ||
appupdate.CompareVersions(policy.MinimumVersion, forcedUpdateFloor) < 0 { appupdate.CompareVersions(policy.MinimumVersion, floor) < 0 {
policy.MinimumVersion = forcedUpdateFloor policy.MinimumVersion = floor
} }
return policy return policy
} }
@@ -116,10 +124,10 @@ func (s *Server) updateDecision(r *http.Request) appupdate.Decision {
// mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a // mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a
// newer release without wanting every otherwise supported session destroyed. Only builds // newer release without wanting every otherwise supported session destroyed. Only builds
// below the permanent compatibility floor are signed out. // below the active destructive floor are signed out.
func mustRetireForUpdate(decision appupdate.Decision, version string) bool { func mustRetireForUpdate(decision appupdate.Decision, version, floor string) bool {
return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" && return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" &&
appupdate.CompareVersions(version, forcedUpdateFloor) < 0 strings.TrimSpace(floor) != "" && appupdate.CompareVersions(version, floor) < 0
} }
// requireSupportedClient prevents a retired build from signing straight back in after // requireSupportedClient prevents a retired build from signing straight back in after
@@ -127,8 +135,9 @@ func mustRetireForUpdate(decision appupdate.Decision, version string) bool {
// available and will keep returning the actionable mandatory verdict. // available and will keep returning the actionable mandatory verdict.
func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc { func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
decision := s.updateDecision(r) policy := s.updatePolicy.get()
if mustRetireForUpdate(decision, clientVersion(r)) { decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r))
if mustRetireForUpdate(decision, clientVersion(r), destructiveUpdateFloor(policy)) {
w.Header().Set("X-Memby-Update-Required", decision.Version) w.Header().Set("X-Memby-Update-Required", decision.Version)
writeJSON(w, http.StatusUpgradeRequired, decision) writeJSON(w, http.StatusUpgradeRequired, decision)
return return
+53 -13
View File
@@ -67,12 +67,13 @@ func TestUpdateOfferLogNamesTheAffectedViewer(t *testing.T) {
} }
} }
func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) { func TestConfiguredFloorForcesClientsBelow0244(t *testing.T) {
server := testServer(config.Config{}) server := testServer(config.Config{})
server.updatePolicy.set(appupdate.Policy{ server.updatePolicy.set(appupdate.Policy{
Enabled: true, Enabled: true,
LatestVersion: "0.2.44", LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed", RetireBelowVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
}) })
for version, want := range map[string]string{ for version, want := range map[string]string{
@@ -95,33 +96,72 @@ func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) {
} }
} }
func TestEmergencyFloorWaitsForAnActionableRelease(t *testing.T) { func TestConfiguredFloorWaitsForAnActionableRelease(t *testing.T) {
for name, policy := range map[string]appupdate.Policy{ for name, policy := range map[string]appupdate.Policy{
"disabled": { "disabled": {
LatestVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk", LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk",
}, },
"missing download": { "missing download": {
Enabled: true, LatestVersion: "0.2.44", Enabled: true, LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44",
}, },
"release too old": { "release too old": {
Enabled: true, LatestVersion: "0.2.43", DownloadURL: "/updates/memby-0.2.43.apk", Enabled: true, LatestVersion: "0.2.43", RetireBelowVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.43.apk",
}, },
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.43") decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.43")
if mustRetireForUpdate(decision, "0.2.43") { if mustRetireForUpdate(decision, "0.2.43", destructiveUpdateFloor(policy)) {
t.Fatal("client would be retired without an actionable 0.2.44-or-newer release") t.Fatal("client would be retired without an actionable release at the configured floor")
} }
}) })
} }
} }
func TestSelectedDestructiveFloorRetiresClientsBelowRelease(t *testing.T) {
policy := appupdate.Policy{
Enabled: true,
LatestVersion: "0.3.0",
RetireBelowVersion: "0.3.0",
DownloadURL: "/updates/memby-0.3.0.apk?token=signed",
}
for version, want := range map[string]bool{
"0.2.99": true,
"0.3.0": false,
"0.3.1": false,
} {
decision := appupdate.Decide(effectiveUpdatePolicy(policy), version)
if got := mustRetireForUpdate(decision, version, destructiveUpdateFloor(policy)); got != want {
t.Errorf("%s: retired = %t, want %t", version, got, want)
}
}
}
func TestRequiredUpdateDoesNotRetireSupportedClient(t *testing.T) {
policy := appupdate.Policy{
Enabled: true,
LatestVersion: "0.3.0",
MinimumVersion: "0.3.0",
DownloadURL: "/updates/memby-0.3.0.apk?token=signed",
}
decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.44")
if decision.Status != appupdate.StatusMandatory {
t.Fatalf("status = %q, want mandatory", decision.Status)
}
if mustRetireForUpdate(decision, "0.2.44", destructiveUpdateFloor(policy)) {
t.Fatal("ordinary required update retired a supported client")
}
}
func TestRetiredClientCannotSignBackIn(t *testing.T) { func TestRetiredClientCannotSignBackIn(t *testing.T) {
server := testServer(config.Config{}) server := testServer(config.Config{})
server.updatePolicy.set(appupdate.Policy{ server.updatePolicy.set(appupdate.Policy{
Enabled: true, Enabled: true,
LatestVersion: "0.2.44", LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed", RetireBelowVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
}) })
reached := false reached := false
handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) { handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) {
+4
View File
@@ -32,6 +32,10 @@ type Policy struct {
// an old release) to keep updates optional; set it to LatestVersion to make the // an old release) to keep updates optional; set it to LatestVersion to make the
// current release mandatory for everyone. // current release mandatory for everyone.
MinimumVersion string `json:"minimumVersion"` 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 points at the APK — normally the same file the landing page serves.
DownloadURL string `json:"downloadUrl"` DownloadURL string `json:"downloadUrl"`
// SHA256 and SizeBytes let the TV reject a truncated, stale or substituted download // SHA256 and SizeBytes let the TV reject a truncated, stale or substituted download
+1 -1
View File
@@ -1 +1 @@
0.1.31 0.1.32