Server-controlled app updates, optional or forced

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>
This commit is contained in:
ponzischeme89
2026-07-27 08:34:04 +12:00
co-authored by Claude Opus 5
parent 08360b75e4
commit 62f6345a40
19 changed files with 1016 additions and 11 deletions
+73 -10
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -27,6 +28,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
return mux
}
@@ -60,11 +62,12 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
}
type adminStatus struct {
Maintenance store.Maintenance `json:"maintenance"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
}
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
@@ -84,14 +87,74 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, adminStatus{
Maintenance: s.maintenance.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
SyncRunning: s.syncer.Running(),
Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
})
}
type updatePolicyRequest struct {
Enabled bool `json:"enabled"`
LatestVersion string `json:"latestVersion"`
DownloadURL string `json:"downloadUrl"`
Notes string `json:"notes"`
// Required makes this release mandatory for everyone below it. The page offers a
// toggle rather than exposing "minimum version" directly, because "force this
// update" is the decision an operator actually wants to make.
Required bool `json:"required"`
// MinimumVersion is honoured when set explicitly, for staged rollouts where the
// forced floor is older than the latest build.
MinimumVersion string `json:"minimumVersion"`
}
func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) {
var req updatePolicyRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
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),
}
if req.Required {
// Forcing means "nobody below the current build", so the floor is the latest.
policy.MinimumVersion = policy.LatestVersion
} else if policy.MinimumVersion == policy.LatestVersion {
// Un-ticking the box must actually release the floor.
policy.MinimumVersion = ""
}
if policy.Enabled && policy.LatestVersion == "" {
writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts")
return
}
if policy.Enabled && policy.DownloadURL == "" {
writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts")
return
}
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
s.log.Error("update policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save the update policy")
return
}
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
s.log.Warn("update policy reload failed", "error", err)
}
s.log.Info("update policy changed",
"enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion)
writeJSON(w, http.StatusOK, s.updatePolicy.get())
}
type syncRequest struct {
Kind string `json:"kind"`
}
+65
View File
@@ -85,6 +85,29 @@
</div>
</section>
<section>
<h2>App updates</h2>
<p class="muted" style="margin-top:0">
TVs check on every launch. <strong>Optional</strong> shows a dismissable prompt;
<strong>required</strong> blocks the home screen until the viewer updates.
</p>
<div class="row" style="margin-bottom:10px">
<input type="text" id="update-version" placeholder="Latest version, e.g. 0.1.54" style="min-width:220px">
<input type="text" id="update-url" placeholder="APK URL, e.g. https://nas/memby/memby-0.1.54.apk" style="min-width:380px">
</div>
<div class="row" style="margin-bottom:10px">
<input type="text" id="update-notes" placeholder="What's new (shown on the TV)" style="min-width:480px">
</div>
<div class="row">
<label class="muted" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="update-required"> Require this update
</label>
<button id="update-save">Save policy</button>
<button id="update-disable" class="secondary">Turn off prompts</button>
<span id="update-state" class="pill"></span>
</div>
</section>
<section>
<h2>Row engagement</h2>
<div class="row" style="margin-bottom:12px">
@@ -195,6 +218,29 @@ function renderStatus(status) {
const messageField = document.getElementById('maintenance-message');
if (document.activeElement !== messageField) messageField.value = maintenance.message || '';
const policy = status.updatePolicy || {};
const policyState = document.getElementById('update-state');
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
if (!policy.enabled) {
policyState.textContent = 'off';
policyState.className = 'pill muted';
} else {
policyState.textContent = required ? 'REQUIRED ' + policy.latestVersion : 'optional ' + policy.latestVersion;
policyState.className = 'pill ' + (required ? 'warn' : 'ok');
}
// Do not fight the operator for the field they are typing in.
const fields = {
'update-version': policy.latestVersion || '',
'update-url': policy.downloadUrl || '',
'update-notes': policy.notes || '',
};
for (const [id, value] of Object.entries(fields)) {
const el = document.getElementById(id);
if (document.activeElement !== el) el.value = value;
}
const requiredBox = document.getElementById('update-required');
if (document.activeElement !== requiredBox) requiredBox.checked = required;
document.getElementById('runs').innerHTML = (status.runs || []).length
? status.runs.map((run) => {
const pill = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad';
@@ -279,6 +325,25 @@ document.getElementById('maintenance-off').addEventListener('click', () =>
body: JSON.stringify({ enabled: false, message: document.getElementById('maintenance-message').value }),
})));
function updatePolicyBody(enabled) {
return JSON.stringify({
enabled,
latestVersion: document.getElementById('update-version').value.trim(),
downloadUrl: document.getElementById('update-url').value.trim(),
notes: document.getElementById('update-notes').value.trim(),
required: document.getElementById('update-required').checked,
});
}
document.getElementById('update-save').addEventListener('click', () => {
if (document.getElementById('update-required').checked &&
!confirm('Required updates block the home screen on every TV below this version. Continue?')) return;
act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(true) }));
});
document.getElementById('update-disable').addEventListener('click', () =>
act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(false) })));
document.getElementById('days').addEventListener('change', refresh);
refresh();
+2
View File
@@ -38,6 +38,7 @@ type Server struct {
recommendationBuilds recommendationBuilds
maintenance maintenanceState
updatePolicy updatePolicyCache
}
// Deps are the collaborators the API needs. A struct rather than positional arguments:
@@ -76,6 +77,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
v1.Handle("GET /v1/update", s.authed(s.handleUpdate))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
+4
View File
@@ -43,6 +43,10 @@ type homeResponse struct {
// Partial is true when at least one row failed upstream. The TV shows what arrived
// and flags a refresh error rather than blanking the screen.
Partial bool `json:"partial"`
// Deliberately no update verdict here: this payload is cached per user, and the
// verdict depends on the *client's* version, so a cached body would hand one TV's
// answer to another running a different build. The client asks /v1/update instead.
}
// handleHome answers the entire launcher in one round trip.
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"context"
"net/http"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/store"
)
// 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.
type updatePolicyCache struct {
mu sync.RWMutex
value appupdate.Policy
}
func (c *updatePolicyCache) get() appupdate.Policy {
c.mu.RLock()
defer c.mu.RUnlock()
return c.value
}
func (c *updatePolicyCache) set(value appupdate.Policy) {
c.mu.Lock()
defer c.mu.Unlock()
c.value = value
}
// LoadUpdatePolicy primes the cached policy. Called at boot and after every change.
func (s *Server) LoadUpdatePolicy(ctx context.Context) error {
policy, err := s.store.UpdatePolicy(ctx)
if err != nil {
return err
}
s.updatePolicy.set(policy)
return nil
}
// WatchUpdatePolicy re-reads the policy periodically, so a change made directly in the
// database is picked up without a restart.
func (s *Server) WatchUpdatePolicy(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.LoadUpdatePolicy(ctx); err != nil {
s.log.Warn("update policy refresh failed", "error", err)
}
}
}
}
// clientVersion reads the version a TV reports. Absent means an older build that predates
// the header — [appupdate.Decide] treats that as "say nothing".
func clientVersion(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-Memby-Version"))
}
// handleUpdate answers the client's version check.
//
// Its own endpoint rather than a field on /v1/home: the home payload is cached per user,
// while this answer depends on the requesting client's version, so the two cannot share a
// cache entry. It costs nothing — the policy is held in memory.
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, _ store.Session) {
decision := appupdate.Decide(s.updatePolicy.get(), clientVersion(r))
writeJSON(w, http.StatusOK, decision)
}
+119
View File
@@ -0,0 +1,119 @@
// 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") }
+118
View File
@@ -0,0 +1,118 @@
package appupdate
import "testing"
func policy() Policy {
return Policy{
Enabled: true,
LatestVersion: "0.1.54",
MinimumVersion: "0.1.50",
DownloadURL: "https://nas.example.com/memby/memby-0.1.54.apk",
Notes: "Faster home screen",
}
}
func TestUpToDateClientIsLeftAlone(t *testing.T) {
for _, version := range []string{"0.1.54", "0.1.55", "0.2.0", "1.0.0"} {
if got := Decide(policy(), version).Status; got != StatusNone {
t.Fatalf("client %s: got %s, want none", version, got)
}
}
}
func TestBehindLatestIsOptional(t *testing.T) {
decision := Decide(policy(), "0.1.53")
if decision.Status != StatusOptional {
t.Fatalf("got %s, want optional", decision.Status)
}
if decision.Version != "0.1.54" || decision.DownloadURL == "" {
t.Fatalf("decision is missing what the TV needs to act: %+v", decision)
}
}
func TestBelowMinimumIsMandatory(t *testing.T) {
if got := Decide(policy(), "0.1.49").Status; got != StatusMandatory {
t.Fatalf("got %s, want mandatory", got)
}
}
// Setting minimum = latest is how an operator forces everyone onto the current build.
func TestMinimumEqualToLatestForcesEveryOldClient(t *testing.T) {
p := policy()
p.MinimumVersion = p.LatestVersion
if got := Decide(p, "0.1.53").Status; got != StatusMandatory {
t.Fatalf("got %s, want mandatory", got)
}
if got := Decide(p, "0.1.54").Status; got != StatusNone {
t.Fatalf("a current client should still be left alone, got %s", got)
}
}
func TestBlankMinimumKeepsUpdatesOptional(t *testing.T) {
p := policy()
p.MinimumVersion = ""
if got := Decide(p, "0.0.1").Status; got != StatusOptional {
t.Fatalf("got %s, want optional", got)
}
}
func TestDisabledPolicySaysNothing(t *testing.T) {
p := policy()
p.Enabled = false
if got := Decide(p, "0.0.1").Status; got != StatusNone {
t.Fatalf("got %s, want none", got)
}
}
func TestPolicyWithNoLatestVersionSaysNothing(t *testing.T) {
p := policy()
p.LatestVersion = " "
if got := Decide(p, "0.0.1").Status; got != StatusNone {
t.Fatalf("got %s, want none", got)
}
}
// A client that cannot report its version must not be hard-blocked: it would be stuck
// behind a prompt it may have no way to satisfy.
func TestUnknownClientVersionIsNeverForced(t *testing.T) {
p := policy()
p.MinimumVersion = p.LatestVersion
for _, version := range []string{"", " ", "?", "unknown"} {
if got := Decide(p, version).Status; got != StatusNone {
t.Fatalf("client %q: got %s, want none", version, got)
}
}
}
func TestVersionParsingIsForgiving(t *testing.T) {
p := policy()
// Leading "v", pre-release suffixes and short versions all compare sensibly.
if got := Decide(p, "v0.1.53").Status; got != StatusOptional {
t.Fatalf("v-prefixed: got %s, want optional", got)
}
if got := Decide(p, "0.1.54-beta").Status; got != StatusNone {
t.Fatalf("pre-release suffix should compare as 0.1.54, got %s", got)
}
p.LatestVersion = "0.2"
if got := Decide(p, "0.2.0").Status; got != StatusNone {
t.Fatalf("0.2.0 should equal 0.2, got %s", got)
}
}
func TestMandatoryOutranksOptional(t *testing.T) {
p := policy()
p.MinimumVersion = "0.1.53"
// Below both thresholds: the stronger verdict must win.
if got := Decide(p, "0.1.52").Status; got != StatusMandatory {
t.Fatalf("got %s, want mandatory", got)
}
}
+38
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/ponzischeme89/memby/server/internal/appupdate"
)
// MaintenanceKey is the app_settings row backing maintenance mode.
@@ -60,6 +61,43 @@ func (s *Store) SetMaintenance(ctx context.Context, state Maintenance) error {
return nil
}
// UpdatePolicyKey is the app_settings row backing the client update policy.
const UpdatePolicyKey = "update_policy"
func (s *Store) UpdatePolicy(ctx context.Context) (appupdate.Policy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, UpdatePolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return appupdate.Policy{}, nil
}
if err != nil {
return appupdate.Policy{}, fmt.Errorf("store: read update policy: %w", err)
}
var policy appupdate.Policy
if err := json.Unmarshal(raw, &policy); err != nil {
return appupdate.Policy{}, fmt.Errorf("store: decode update policy: %w", err)
}
return policy, nil
}
func (s *Store) SetUpdatePolicy(ctx context.Context, policy appupdate.Policy) error {
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
UpdatePolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write update policy: %w", err)
}
return nil
}
// NewestSession is the fallback credential for the library import: whichever TV signed
// in most recently. It means a fresh deployment can import without configuring a
// service account, at the cost of the import stopping if that user is ever removed.