0.2.45 - Server side git commits
This commit is contained in:
@@ -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
@@ -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`:
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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', () =>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
0.1.31
|
0.1.32
|
||||||
|
|||||||
Reference in New Issue
Block a user