feat(ota): self-update kill switch — global, per-device, and MDM auto-detect (#166)

Lets an operator (or an MDM) own updates instead of the app self-installing, which
on managed panels shows a self-install confirm dialog over customer content
(#155). Three layered controls:

- GLOBAL (server): config.otaEnabled from OTA_ENABLED (default on). When off,
  /api/update/check returns update_available:false, reason:ota_disabled_global —
  the whole instance stops offering updates.
- PER-DEVICE (server + dashboard): new devices.ota_enabled column (default 1). When
  0, that device is never offered an update (reason:ota_disabled_device). A
  "Self-update (OTA)" toggle in the device settings flips it via PUT /api/devices/:id.
- AUTO-DETECT (Android): UpdateChecker stands down entirely when a foreign device
  owner (an MDM/DPC) manages the panel — detected via getActiveAdmins() + not being
  device owner ourselves. Pure client-side, errs safe, needs no server change.

The two server gates are enforced server-side so they cover EVERY client version,
not just ones with the client-side stand-down. When OTA is off the device still
reports its version (dashboard sees state); the MDM/operator owns the actual update.

For an MDM-managed fleet (e.g. Pivot/MAXHUB), turn OTA off and let the MDM push the
APK — the install-dialog race disappears from every angle.

Tests: +2 (per-device gate + a real OTA_ENABLED=false server for the global gate);
full server suite 393 pass; Android compiles.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-10 22:38:58 -05:00 committed by GitHub
parent c63af0e6bd
commit 1ebdb1f7a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 104 additions and 1 deletions

View file

@ -115,6 +115,14 @@ class UpdateChecker(private val context: Context) {
fun checkForUpdate() {
if (config.serverUrl.isEmpty()) return
// #155/#161: if a foreign device owner (an MDM/DPC) manages this panel, IT owns updates.
// Stand down — never self-install: on a managed device the self-install confirm dialog
// can't be reliably auto-dismissed and ends up over customer content. The MDM pushes the
// APK instead. Pure client-side safety net, independent of the server-side OTA switch.
if (isManagedByForeignDeviceOwner()) {
Log.i(TAG, "Managed by a foreign device owner (MDM) — self-OTA stands down; MDM owns updates")
return
}
Thread {
try {
@ -421,4 +429,20 @@ class UpdateChecker(private val context: Context) {
"1.0.0"
}
}
// #155/#161: true when an MDM / foreign device owner manages this device — i.e. a device
// admin belonging to ANOTHER package is active and we are NOT the device owner ourselves.
// A normal app can't read the device-owner component directly, so we use the public
// getActiveAdmins() signal: any active admin outside our package means a DPC owns the box.
// Errs safe (managed => stand down) on a signage panel. Never throws.
private fun isManagedByForeignDeviceOwner(): Boolean {
return try {
val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE)
as? android.app.admin.DevicePolicyManager ?: return false
if (dpm.isDeviceOwnerApp(context.packageName)) return false // WE own it — not foreign
dpm.activeAdmins?.any { it.packageName != context.packageName } == true
} catch (_: Throwable) {
false
}
}
}

View file

@ -322,6 +322,8 @@ export default {
'device.form.notes_placeholder': 'Location, setup details, etc.',
'device.debug.toggle': 'Debug logging (live)',
'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.',
'device.ota.toggle': 'Self-update (OTA)',
'device.ota.hint': 'When off, this device is never offered an update — an MDM or operator owns its updates instead. Turn OFF for MDM-managed panels (e.g. Pivot/MAXHUB) so the app never shows a self-install dialog.',
'device.form.save_settings': 'Save Settings',
// #150 re-adopt: restore a removed device's saved settings onto this one
'device.readopt.button': 'Restore from removed device…',

View file

@ -376,6 +376,12 @@ async function loadDevice(deviceId, activeTab = null) {
<label>${t('device.form.notes_label')}</label>
<textarea id="deviceNotes" class="input" rows="3" placeholder="${t('device.form.notes_placeholder')}" style="resize:vertical">${esc(device.notes || '')}</textarea>
</div>
<div style="margin:12px 0">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px">
<input type="checkbox" id="otaToggle" ${device.ota_enabled === 0 ? '' : 'checked'}> ${t('device.ota.toggle')}
</label>
<div style="font-size:11px;color:var(--text-muted);margin:4px 0 0 24px">${t('device.ota.hint')}</div>
</div>
<button class="btn btn-secondary btn-sm" id="saveNotesBtn">${t('device.form.save_settings')}</button>
<button class="btn btn-secondary btn-sm" id="reAdoptBtn" style="margin-left:8px" title="${t('device.readopt.button_hint')}">${t('device.readopt.button')}</button>
</div>
@ -802,6 +808,7 @@ function setupActions(device) {
notes: document.getElementById('deviceNotes').value,
orientation: document.getElementById('deviceOrientation').value,
default_content_id: document.getElementById('deviceDefaultContent').value || null,
ota_enabled: document.getElementById('otaToggle')?.checked ? 1 : 0,
});
showToast(t('device.toast.settings_saved'), 'success');
} catch (err) {

View file

@ -30,6 +30,12 @@ module.exports = {
screenshotsDir: path.join(uploadsDir, 'screenshots'),
certsDir,
frontendDir: path.join(__dirname, '..', 'frontend'),
// #155/#161: self-update (OTA) master switch. When false, the server offers NO update
// to ANY device (/api/update/check returns update_available:false), so an MDM/operator
// owns updates instead of the app self-installing (which prompts a confirm dialog on
// managed panels). Per-device override lives on devices.ota_enabled; the app additionally
// stands down on its own when a foreign device owner (MDM) manages it.
otaEnabled: process.env.OTA_ENABLED !== 'false',
// App-level heartbeat. Checker runs every heartbeatInterval and marks
// devices offline if last_heartbeat is older than heartbeatTimeout.
// Env override for self-hosters on slow/jittery networks (issue #3:

View file

@ -267,6 +267,10 @@ const migrations = [
// settings_pin: 6-digit PIN for the in-app hidden settings menu, provisioned by
// the server during pairing so each device gets a unique PIN (never a hardcoded default).
"ALTER TABLE devices ADD COLUMN settings_pin TEXT",
// #155/#161: per-device self-update (OTA) switch. 0 => the server never offers this
// device an update (an MDM/operator owns its updates). Default 1 (self-update on).
// UPDATE devices SET ota_enabled = 0 WHERE id = '<device_id>'; (1 to re-enable)
"ALTER TABLE devices ADD COLUMN ota_enabled INTEGER NOT NULL DEFAULT 1",
// Backfill a unique 6-digit PIN for already-paired devices that predate the
// settings_pin column (their next reconnect re-sends device:paired with it, so
// the existing fleet isn't locked out of the on-device menu). Idempotent: the

View file

@ -210,7 +210,7 @@ router.put('/:id', (req, res) => {
const device = checkDeviceOwnership(req, res);
if (!device) return;
const { name, notes, timezone, orientation, default_content_id, layout_id } = req.body;
const { name, notes, timezone, orientation, default_content_id, layout_id, ota_enabled } = req.body;
// #150: validate orientation against the known enum (previously accepted any string, which
// let a bad value reach the player -> unknown rotation falls back to landscape silently).
if (orientation !== undefined && !deviceSettings.ORIENTATIONS.has(orientation)) {
@ -236,6 +236,10 @@ router.put('/:id', (req, res) => {
}
updates.push('layout_id = ?'); values.push(layout_id || null);
}
// #155/#161: per-device self-update (OTA) toggle. Coerce to 0/1.
if (ota_enabled !== undefined) {
updates.push('ota_enabled = ?'); values.push(ota_enabled ? 1 : 0);
}
if (updates.length > 0) {
values.push(req.params.id);
db.prepare(`UPDATE devices SET ${updates.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...values);

View file

@ -617,6 +617,30 @@ app.get('/api/update/check', (req, res) => {
const deviceId = req.query.device_id || null; // #144: optional; beta4+ clients send it for per-device keying
const latestVersion = VERSION;
// #155/#161: self-update kill switch, enforced SERVER-SIDE so it covers EVERY client
// version (not just ones with the client-side stand-down). If OTA is off globally
// (config.otaEnabled) or for this device (devices.ota_enabled=0), never offer an update
// — an MDM/operator owns updates instead. Checked before the breaker so a disabled device
// does zero further work.
{
const otaGloballyOff = !config.otaEnabled;
let otaDeviceOff = false;
if (deviceId) {
try {
const row = require('./db/database').db.prepare('SELECT ota_enabled FROM devices WHERE id = ?').get(deviceId);
otaDeviceOff = !!row && row.ota_enabled === 0;
} catch (_) { /* device unknown / pre-migration — treat as enabled */ }
}
if (otaGloballyOff || otaDeviceOff) {
const reason = otaGloballyOff ? 'ota_disabled_global' : 'ota_disabled_device';
logOtaCheck(deviceId, currentVersion, latestVersion, false, reason);
return res.json({
latest_version: latestVersion, current_version: currentVersion || 'unknown',
update_available: false, reason, download_url: '/download/apk', apk_size: 0, apk_modified: 0,
});
}
}
// #144: circuit-breaker + phantom-version guard. Keys per device_id when present, else
// per reported version (NOT IP — SNAT). Rate-trips a looping client in seconds.
const verdict = otaBreaker.decide(currentVersion, latestVersion, deviceId);

View file

@ -68,3 +68,35 @@ test('(e) device_id looping is throttled per-device; another device on the same
const bOk = await check(v, 'devB'); // devB first check -> offered
assert.equal(bOk.update_available, true, 'devB (same version, different device) unaffected');
});
// #155/#161 self-update kill switch
test('per-device OTA off (devices.ota_enabled=0) -> never offered (reason ota_disabled_device); an enabled device still is', async () => {
const Database = require('better-sqlite3');
const db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'), { timeout: 5000 });
db.prepare('INSERT INTO devices (id, ota_enabled) VALUES (?, 0)').run('ota-off-dev');
db.prepare('INSERT INTO devices (id, ota_enabled) VALUES (?, 1)').run('ota-on-dev');
db.close();
const off = await check('1.4.0', 'ota-off-dev');
assert.equal(off.update_available, false, 'OTA-disabled device is not offered an update');
assert.equal(off.reason, 'ota_disabled_device');
const on = await check('1.4.1', 'ota-on-dev');
assert.equal(on.update_available, true, 'OTA-enabled device is still offered');
});
test('global OTA off (OTA_ENABLED=false) -> no device is offered (reason ota_disabled_global)', async () => {
const P2 = 3992;
const DD2 = path.join(os.tmpdir(), 'st-ota2-' + crypto.randomBytes(4).toString('hex'));
fs.mkdirSync(DD2, { recursive: true });
fs.writeFileSync(path.join(DD2, 'ScreenTinker.apk'), Buffer.alloc(1024, 1));
const p2 = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR: DD2, SELF_HOSTED: 'true', PORT: String(P2), NODE_ENV: 'test', OTA_ENABLED: 'false' }, stdio: 'ignore' });
try {
let up = false;
for (let i = 0; i < 80; i++) { try { const r = await fetch(`http://127.0.0.1:${P2}/api/status`); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
assert.ok(up, 'OTA_ENABLED=false server booted');
const r = await (await fetch(`http://127.0.0.1:${P2}/api/update/check?version=1.0.0`)).json();
assert.equal(r.update_available, false, 'global-off: no update offered');
assert.equal(r.reason, 'ota_disabled_global');
} finally {
try { p2.kill('SIGKILL'); } catch { /* */ }
}
});