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"`
}