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
@@ -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