From 1ebdb1f7a9cb8adfc40e92233cde4713d2f1538e Mon Sep 17 00:00:00 2001 From: screentinker Date: Fri, 10 Jul 2026 22:38:58 -0500 Subject: [PATCH] =?UTF-8?q?feat(ota):=20self-update=20kill=20switch=20?= =?UTF-8?q?=E2=80=94=20global,=20per-device,=20and=20MDM=20auto-detect=20(?= =?UTF-8?q?#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../player/service/UpdateChecker.kt | 24 ++++++++++++++ frontend/js/i18n/en.js | 2 ++ frontend/js/views/device-detail.js | 7 ++++ server/config.js | 6 ++++ server/db/database.js | 4 +++ server/routes/devices.js | 6 +++- server/server.js | 24 ++++++++++++++ server/test/ota-check.test.js | 32 +++++++++++++++++++ 8 files changed, 104 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt index deeff42..6835dc5 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt @@ -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 + } + } } diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 55091f5..89bf6f3 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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…', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 33499a7..d8e0785 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -376,6 +376,12 @@ async function loadDevice(deviceId, activeTab = null) { +
+ +
${t('device.ota.hint')}
+
@@ -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) { diff --git a/server/config.js b/server/config.js index 384f2e9..7052028 100644 --- a/server/config.js +++ b/server/config.js @@ -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: diff --git a/server/db/database.js b/server/db/database.js index 9bbc70f..1cb2f49 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -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 = ''; (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 diff --git a/server/routes/devices.js b/server/routes/devices.js index 9a4d393..f4ac2c5 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -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); diff --git a/server/server.js b/server/server.js index 1ecd2c4..ada5f26 100644 --- a/server/server.js +++ b/server/server.js @@ -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); diff --git a/server/test/ota-check.test.js b/server/test/ota-check.test.js index 74a26a4..1c62472 100644 --- a/server/test/ota-check.test.js +++ b/server/test/ota-check.test.js @@ -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 { /* */ } + } +});