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 { /* */ }
+ }
+});