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:
co-authored by
Claude Opus 5
parent
08360b75e4
commit
62f6345a40
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user