mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 22:33:12 -06:00
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:
parent
c63af0e6bd
commit
1ebdb1f7a9
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 { /* */ }
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue