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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user