Build / build (push) Successful in 46s
Blocking snu.lge.com in /etc/hosts never stopped the boot popup on a CX, and the reason only showed up on the TV itself: /usr/sbin/update is a systemd unit (webos-mbd.target) that runs its version check against https://snu.lge.com/CheckSWAutoUpdate.laf 15-20 seconds before the Homebrew Channel gets as far as running its init.d hooks. The check therefore succeeds on every boot and the alert is already on screen before any hosts entry exists. Homebrew Channel's own "block system updates" toggle loses the same race. Nothing running that late can win it: every systemd unit path is a read-only overlay except tmpfs /run, and no persistent setting gates the check - automaticUpdate, support/softwareUpdateEnable, hotelMode/swUpdateEnable and .UpdateIsInprogress were each measured by restarting the daemon and counting its requests. So dismiss the popup instead. The boot hook recovers the alert id from the updater's own log (_gAlertWindowId), which is the only way to reach an alert that opened before we could subscribe - com.webos.notification never reports it to a late subscriber and closeAllAlerts rejects every source id it accepts. A companion alert-watch.sh then stays subscribed for the rest of the session. Also drop two things that were never true. There is no staged firmware image driving the popup (the staging dir is empty at boot; the size the daemon reports is in-memory only), and there is no update service to stop - /etc/init is dead upstart leftovers on a systemd TV, so the old stopServices layer printed "stopped update" while doing nothing. - rename the hook to 00-lgupdateblocker so run-parts runs it first, removing the legacy file on apply - add support/ and hotelMode/ to the scanned settings categories - stop matching "ota" inside screenRotation, which would have switched screen rotation off - kill the watcher by process group, and make its TERM trap exit - a trap that only returns resumes the script, which then re-subscribes Verified on an LG OLED55CX8LB (webOS 5, 04.60.65): after a reboot the boot log records "dismissed update popup com.webos.service.update-1788650782451", matching the id the updater logged that boot. Raised 01:26:22, closed 01:26:37 - so it is visible for ~15s and then goes away on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1041 lines
32 KiB
JavaScript
1041 lines
32 KiB
JavaScript
/**
|
|
* LG Update Blocker - root service.
|
|
*
|
|
* Everything this service does is reversible and is applied in layers:
|
|
*
|
|
* 1. hosts - point LG's firmware update servers at 127.0.0.1
|
|
* 2. dismiss - close the "software update available" alert at boot
|
|
* 3. purge - delete the firmware image the TV already staged
|
|
* 4. lock - bind-mount an empty read-only dir over the staging dir
|
|
* 5. settings - switch off the update-related com.webos.settingsservice keys
|
|
*
|
|
* / is read-only on webOS, and /tmp, /etc and the mount namespace are reset on
|
|
* every boot, so each layer is re-applied by a boot hook script dropped into
|
|
* /var/lib/webosbrew/init.d (run by the Homebrew Channel startup script).
|
|
*
|
|
* Why the hosts block alone is not enough: /usr/sbin/update is started by
|
|
* systemd (webos-mbd.target) and runs its version check against snu.lge.com
|
|
* roughly 20 seconds before the Homebrew Channel gets as far as running the
|
|
* hooks in init.d. On a CX that check therefore *succeeds* on every boot and
|
|
* the popup is already on screen by the time anything of ours runs - which is
|
|
* also why the Homebrew Channel's own "block system updates" toggle does not
|
|
* stop it. Nothing persistent gates that check (automaticUpdate,
|
|
* support/softwareUpdateEnable, hotelMode/swUpdateEnable and the
|
|
* .UpdateIsInprogress flag were all measured to make no difference), and there
|
|
* is no writable directory early enough in the boot to win the race: every
|
|
* systemd unit path except /run is a read-only overlay. So the popup is closed
|
|
* instead of prevented, and the hosts block stops the download and every
|
|
* later check.
|
|
*
|
|
* Written in ES5 without dependencies - it runs on the TV's own node with the
|
|
* platform-provided webos-service module and nothing else.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
var fs = require('fs');
|
|
var path = require('path');
|
|
var childProcess = require('child_process');
|
|
var Service = require('webos-service');
|
|
|
|
var pkgInfo = require('./package.json');
|
|
|
|
var service = new Service(pkgInfo.name);
|
|
service.activityManager.idleTimeout = 300;
|
|
|
|
var SLUG = 'lgupdateblocker';
|
|
var STATE_DIR = '/var/lib/webosbrew/' + SLUG;
|
|
var CONFIG_PATH = STATE_DIR + '/config.json';
|
|
var HOSTS_LIST_PATH = STATE_DIR + '/hosts.txt';
|
|
var SETTINGS_BACKUP_PATH = STATE_DIR + '/settings-backup.json';
|
|
var EMPTY_DIR = STATE_DIR + '/empty';
|
|
var BOOT_LOG_PATH = STATE_DIR + '/boot.log';
|
|
var BOOT_HOOK_DIR = '/var/lib/webosbrew/init.d';
|
|
/* run-parts runs the hooks in lexical order and the popup is already on screen
|
|
* by then, so sort ahead of the other homebrew hooks. Older versions installed
|
|
* the hook under the bare slug; installBootHook removes that leftover. */
|
|
var BOOT_HOOK_PATH = BOOT_HOOK_DIR + '/00-' + SLUG;
|
|
var LEGACY_BOOT_HOOK_PATH = BOOT_HOOK_DIR + '/' + SLUG;
|
|
var WATCHER_PATH = STATE_DIR + '/alert-watch.sh';
|
|
var WATCHER_PID_PATH = '/tmp/' + SLUG + '-watch.pid';
|
|
|
|
var HOSTS_PATH = '/etc/hosts';
|
|
var HOSTS_TMP = '/tmp/' + SLUG + '-hosts';
|
|
var MARKER_BEGIN = '# >>> lg-update-blocker >>>';
|
|
var MARKER_END = '# <<< lg-update-blocker <<<';
|
|
|
|
var BUNDLED_HOSTS = path.join(__dirname, 'data', 'lg-update-hosts.txt');
|
|
|
|
/* Directories where the TV stages a downloaded firmware image. A staged image
|
|
* keeps the "update is ready" popup coming back even when the update servers
|
|
* are unreachable. Only paths below /mnt/lg are ever touched. */
|
|
var CACHE_DIRS = [
|
|
'/mnt/lg/cmn_data/swupdate',
|
|
'/mnt/lg/swupdate',
|
|
'/mnt/lg/cmn_data/var/palm/data/com.webos.service.swupdate'
|
|
];
|
|
var CACHE_GUARD = '/mnt/lg/';
|
|
|
|
/* /usr/sbin/update logs its own alert id here as
|
|
* _NSU_CreateAlertCallback - _gAlertWindowId : com.webos.service.update-<ms>
|
|
* which is the only way to learn the id of a popup that opened before we did:
|
|
* com.webos.notification only pushes alerts to clients already subscribed, and
|
|
* closeAllAlerts rejects every source id we can pass it. */
|
|
var UPDATE_DAEMON_LOG = '/tmp/var/log/update.log';
|
|
var ALERT_ID_PATTERN = 'com\\.webos\\.service\\.update-[0-9]*';
|
|
|
|
var SETTINGS_CATEGORIES = ['option', 'general', 'network', 'commercial', 'support', 'hotelMode'];
|
|
var SETTINGS_KEY_PATTERN = /(update|upgrade|firmware)/i;
|
|
/* "ota" and "nsu" are substrings of perfectly innocent keys - screenRotation,
|
|
* consumerMode - and switching one of those off would be a nasty surprise, so
|
|
* only match them at a word or camelCase boundary. */
|
|
var SETTINGS_ABBREV_PATTERN = /(^|[^A-Za-z])(ota|nsu)|Ota|OTA|Nsu|NSU/;
|
|
|
|
function isUpdateKey(key) {
|
|
return SETTINGS_KEY_PATTERN.test(key) || SETTINGS_ABBREV_PATTERN.test(key);
|
|
}
|
|
|
|
var DEFAULT_CONFIG = {
|
|
blockHosts: true,
|
|
dismissPopup: true,
|
|
purgeCache: true,
|
|
lockCache: false,
|
|
disableSettings: true
|
|
};
|
|
var CONFIG_KEYS = Object.keys(DEFAULT_CONFIG);
|
|
|
|
/* ------------------------------------------------------------------ utils */
|
|
|
|
function isRoot() {
|
|
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
}
|
|
|
|
function exists(target) {
|
|
try {
|
|
fs.statSync(target);
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isDirectory(target) {
|
|
try {
|
|
return fs.statSync(target).isDirectory();
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function mkdirp(dir) {
|
|
if (isDirectory(dir)) return;
|
|
mkdirp(path.dirname(dir));
|
|
try {
|
|
fs.mkdirSync(dir);
|
|
} catch (err) {
|
|
if (!isDirectory(dir)) throw err;
|
|
}
|
|
}
|
|
|
|
function readFile(target, fallback) {
|
|
try {
|
|
return fs.readFileSync(target, 'utf8');
|
|
} catch (err) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function readJson(target, fallback) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
} catch (err) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function writeJson(target, value) {
|
|
mkdirp(path.dirname(target));
|
|
fs.writeFileSync(target, JSON.stringify(value, null, 2) + '\n');
|
|
}
|
|
|
|
function sh(command) {
|
|
try {
|
|
var out = childProcess.execSync(command + ' 2>&1', { encoding: 'utf8', timeout: 30000 });
|
|
return { ok: true, output: String(out || '').trim() };
|
|
} catch (err) {
|
|
var output = (err && (err.stdout || err.message)) || '';
|
|
return { ok: false, output: String(output).trim() };
|
|
}
|
|
}
|
|
|
|
function Log() {
|
|
this.lines = [];
|
|
}
|
|
Log.prototype.add = function (line) {
|
|
console.info(line);
|
|
this.lines.push(line);
|
|
return this;
|
|
};
|
|
|
|
/* ----------------------------------------------------------------- config */
|
|
|
|
function readConfig() {
|
|
var stored = readJson(CONFIG_PATH, {});
|
|
var config = {};
|
|
CONFIG_KEYS.forEach(function (key) {
|
|
config[key] = typeof stored[key] === 'boolean' ? stored[key] : DEFAULT_CONFIG[key];
|
|
});
|
|
return config;
|
|
}
|
|
|
|
function mergeConfig(config, payload) {
|
|
var merged = {};
|
|
CONFIG_KEYS.forEach(function (key) {
|
|
merged[key] = typeof payload[key] === 'boolean' ? payload[key] : config[key];
|
|
});
|
|
return merged;
|
|
}
|
|
|
|
function anyEnabled(config) {
|
|
return CONFIG_KEYS.some(function (key) {
|
|
return config[key];
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ hosts */
|
|
|
|
function readDomains() {
|
|
var raw = readFile(BUNDLED_HOSTS, null);
|
|
if (raw === null) raw = readFile(HOSTS_LIST_PATH, '');
|
|
return raw
|
|
.split('\n')
|
|
.map(function (line) {
|
|
return line.replace(/#.*$/, '').trim();
|
|
})
|
|
.filter(function (line) {
|
|
return line.length > 0 && line.indexOf(' ') === -1;
|
|
});
|
|
}
|
|
|
|
function mountTargets() {
|
|
return readFile('/proc/mounts', '')
|
|
.split('\n')
|
|
.map(function (line) {
|
|
return line.split(' ')[1];
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function isMountPoint(target) {
|
|
return mountTargets().indexOf(target) !== -1;
|
|
}
|
|
|
|
function hostsWritable() {
|
|
try {
|
|
fs.appendFileSync(HOSTS_PATH, '');
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/* / is mounted read-only, so /etc/hosts gets replaced by a bind mount of a
|
|
* writable copy in /tmp - the same trick the Homebrew Channel startup script
|
|
* uses (and it happily stacks with it). */
|
|
function makeHostsWritable(log) {
|
|
if (hostsWritable()) return true;
|
|
try {
|
|
fs.writeFileSync(HOSTS_TMP, readFile(HOSTS_PATH, ''));
|
|
fs.chmodSync(HOSTS_TMP, parseInt('644', 8));
|
|
} catch (err) {
|
|
log.add('! could not stage a writable hosts file: ' + err.message);
|
|
return false;
|
|
}
|
|
var res = sh('mount --bind ' + HOSTS_TMP + ' ' + HOSTS_PATH);
|
|
if (!res.ok || !hostsWritable()) {
|
|
log.add('! bind mount of ' + HOSTS_PATH + ' failed: ' + res.output);
|
|
return false;
|
|
}
|
|
log.add('bind-mounted a writable ' + HOSTS_PATH + ' (from ' + HOSTS_TMP + ')');
|
|
return true;
|
|
}
|
|
|
|
function stripHostsBlock(content) {
|
|
var out = [];
|
|
var inside = false;
|
|
content.split('\n').forEach(function (line) {
|
|
if (line.indexOf(MARKER_BEGIN) === 0) {
|
|
inside = true;
|
|
return;
|
|
}
|
|
if (inside) {
|
|
if (line.indexOf(MARKER_END) === 0) inside = false;
|
|
return;
|
|
}
|
|
out.push(line);
|
|
});
|
|
return out.join('\n').replace(/\n{3,}$/, '\n');
|
|
}
|
|
|
|
function buildHostsBlock(domains) {
|
|
var lines = [MARKER_BEGIN];
|
|
domains.forEach(function (domain) {
|
|
lines.push('127.0.0.1 ' + domain);
|
|
lines.push('::1 ' + domain);
|
|
});
|
|
lines.push(MARKER_END);
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
function hostsBlockedDomains() {
|
|
var content = readFile(HOSTS_PATH, '');
|
|
if (content.indexOf(MARKER_BEGIN) === -1) return 0;
|
|
return readDomains().filter(function (domain) {
|
|
return content.indexOf(' ' + domain) !== -1;
|
|
}).length;
|
|
}
|
|
|
|
function applyHosts(log) {
|
|
var domains = readDomains();
|
|
if (!domains.length) {
|
|
log.add('! no update domains found to block');
|
|
return false;
|
|
}
|
|
if (!makeHostsWritable(log)) return false;
|
|
|
|
var content = stripHostsBlock(readFile(HOSTS_PATH, ''));
|
|
if (content.length && content.charAt(content.length - 1) !== '\n') content += '\n';
|
|
fs.writeFileSync(HOSTS_PATH, content + buildHostsBlock(domains));
|
|
log.add('blocked ' + domains.length + ' update hostnames in ' + HOSTS_PATH);
|
|
return true;
|
|
}
|
|
|
|
function removeHosts(log) {
|
|
var content = readFile(HOSTS_PATH, '');
|
|
if (content.indexOf(MARKER_BEGIN) === -1) {
|
|
log.add('no hosts entries to remove');
|
|
return true;
|
|
}
|
|
if (!hostsWritable()) {
|
|
log.add('! ' + HOSTS_PATH + ' is not writable, entries stay until reboot');
|
|
return false;
|
|
}
|
|
fs.writeFileSync(HOSTS_PATH, stripHostsBlock(content));
|
|
log.add('removed update hostnames from ' + HOSTS_PATH);
|
|
return true;
|
|
}
|
|
|
|
/* ------------------------------------------------------- staged firmware */
|
|
|
|
function rmrf(target) {
|
|
if (target.indexOf(CACHE_GUARD) !== 0) throw new Error('refusing to delete ' + target);
|
|
var stat;
|
|
try {
|
|
stat = fs.lstatSync(target);
|
|
} catch (err) {
|
|
return;
|
|
}
|
|
if (stat.isDirectory()) {
|
|
fs.readdirSync(target).forEach(function (entry) {
|
|
rmrf(path.join(target, entry));
|
|
});
|
|
fs.rmdirSync(target);
|
|
} else {
|
|
fs.unlinkSync(target);
|
|
}
|
|
}
|
|
|
|
function dirBytes(target, depth) {
|
|
var total = 0;
|
|
var stat;
|
|
try {
|
|
stat = fs.lstatSync(target);
|
|
} catch (err) {
|
|
return 0;
|
|
}
|
|
if (!stat.isDirectory()) return stat.size;
|
|
if (depth > 8) return 0;
|
|
fs.readdirSync(target).forEach(function (entry) {
|
|
total += dirBytes(path.join(target, entry), depth + 1);
|
|
});
|
|
return total;
|
|
}
|
|
|
|
function cacheStatus() {
|
|
return CACHE_DIRS.filter(isDirectory).map(function (dir) {
|
|
var entries = [];
|
|
try {
|
|
entries = fs.readdirSync(dir);
|
|
} catch (err) {
|
|
entries = [];
|
|
}
|
|
return {
|
|
path: dir,
|
|
entries: entries,
|
|
bytes: dirBytes(dir, 0),
|
|
locked: isMountPoint(dir)
|
|
};
|
|
});
|
|
}
|
|
|
|
function purgeCache(log) {
|
|
var dirs = CACHE_DIRS.filter(isDirectory);
|
|
if (!dirs.length) {
|
|
log.add('no firmware staging directory present on this TV');
|
|
return;
|
|
}
|
|
dirs.forEach(function (dir) {
|
|
if (isMountPoint(dir)) {
|
|
log.add(dir + ' is locked (empty read-only mount), nothing staged');
|
|
return;
|
|
}
|
|
var entries;
|
|
try {
|
|
entries = fs.readdirSync(dir);
|
|
} catch (err) {
|
|
log.add('! cannot read ' + dir + ': ' + err.message);
|
|
return;
|
|
}
|
|
if (!entries.length) {
|
|
log.add(dir + ' is already empty');
|
|
return;
|
|
}
|
|
var removed = 0;
|
|
entries.forEach(function (entry) {
|
|
try {
|
|
rmrf(path.join(dir, entry));
|
|
removed += 1;
|
|
} catch (err) {
|
|
log.add('! could not delete ' + entry + ' in ' + dir + ': ' + err.message);
|
|
}
|
|
});
|
|
log.add('purged ' + removed + '/' + entries.length + ' staged item(s) from ' + dir);
|
|
});
|
|
}
|
|
|
|
function lockCache(log) {
|
|
var dirs = CACHE_DIRS.filter(isDirectory);
|
|
if (!dirs.length) {
|
|
log.add('no firmware staging directory to lock');
|
|
return;
|
|
}
|
|
mkdirp(EMPTY_DIR);
|
|
dirs.forEach(function (dir) {
|
|
if (isMountPoint(dir)) {
|
|
log.add(dir + ' is already locked');
|
|
return;
|
|
}
|
|
var res = sh('mount -o bind,ro ' + EMPTY_DIR + ' ' + dir);
|
|
if (!res.ok) {
|
|
log.add('! could not lock ' + dir + ': ' + res.output);
|
|
return;
|
|
}
|
|
// Older busybox mount ignores "ro" on the initial bind, so remount.
|
|
sh('mount -o bind,remount,ro ' + EMPTY_DIR + ' ' + dir);
|
|
log.add('locked ' + dir + ' (empty, read-only)');
|
|
});
|
|
}
|
|
|
|
function unlockCache(log) {
|
|
CACHE_DIRS.forEach(function (dir) {
|
|
if (!isMountPoint(dir)) return;
|
|
var res = sh('umount ' + dir);
|
|
log.add(res.ok ? 'unlocked ' + dir : '! could not unlock ' + dir + ': ' + res.output);
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------ update popup */
|
|
|
|
/* Id of the last update alert the daemon raised this boot, recovered from its
|
|
* own log. /tmp is wiped at boot, so at boot time this is the popup currently
|
|
* on screen; later in a session it may already have been closed. Closing an
|
|
* alert that is gone is a no-op, so it is safe either way. */
|
|
function lastAlertId() {
|
|
var log = readFile(UPDATE_DAEMON_LOG, '');
|
|
var matches = log.match(/_gAlertWindowId\s*:\s*(com\.webos\.service\.update-[0-9]+)/g);
|
|
if (!matches || !matches.length) return null;
|
|
return matches[matches.length - 1].replace(/^.*:\s*/, '');
|
|
}
|
|
|
|
function closeAlert(alertId, callback) {
|
|
callLuna('luna://com.webos.notification/closeAlert', { alertId: alertId }, callback);
|
|
}
|
|
|
|
function watcherPid() {
|
|
var pid = parseInt(readFile(WATCHER_PID_PATH, ''), 10);
|
|
if (!pid || !exists('/proc/' + pid)) return 0;
|
|
/* the pid file survives a crash; make sure it is still our watcher */
|
|
var cmdline = readFile('/proc/' + pid + '/cmdline', '');
|
|
return cmdline.indexOf('alert-watch') >= 0 ? pid : 0;
|
|
}
|
|
|
|
function stopWatcher() {
|
|
var pid = watcherPid();
|
|
if (!pid) return false;
|
|
/* the watcher is a pipeline in its own session (setsid / detached spawn), so
|
|
* kill the whole group - killing the script alone orphans its luna-send */
|
|
sh('kill -TERM -' + pid + ' 2>/dev/null || kill ' + pid);
|
|
try {
|
|
fs.unlinkSync(WATCHER_PID_PATH);
|
|
} catch (err) {
|
|
/* already gone */
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function startWatcher(log) {
|
|
if (watcherPid()) {
|
|
log.add('alert watcher already running');
|
|
return;
|
|
}
|
|
if (!exists(WATCHER_PATH)) {
|
|
log.add('! alert watcher missing at ' + WATCHER_PATH);
|
|
return;
|
|
}
|
|
try {
|
|
var child = childProcess.spawn('/bin/sh', [WATCHER_PATH], {
|
|
detached: true,
|
|
stdio: 'ignore'
|
|
});
|
|
child.unref();
|
|
log.add('alert watcher started');
|
|
} catch (err) {
|
|
log.add('! could not start alert watcher: ' + err.message);
|
|
}
|
|
}
|
|
|
|
/* Close a popup that is on screen right now, then keep watching for the next
|
|
* one. Used by apply(); at boot the hook does the same thing in shell. */
|
|
function dismissPopup(log, done) {
|
|
var alertId = lastAlertId();
|
|
if (!alertId) {
|
|
log.add('no update popup raised this boot');
|
|
startWatcher(log);
|
|
return done();
|
|
}
|
|
closeAlert(alertId, function (res) {
|
|
log.add(
|
|
res && res.returnValue
|
|
? 'closed update popup ' + alertId
|
|
: '! could not close ' + alertId + ': ' + ((res && res.errorText) || 'no response')
|
|
);
|
|
startWatcher(log);
|
|
done();
|
|
});
|
|
}
|
|
|
|
/* --------------------------------------------------------------- settings */
|
|
|
|
function callLuna(uri, params, callback) {
|
|
var done = false;
|
|
var timer = setTimeout(function () {
|
|
if (done) return;
|
|
done = true;
|
|
callback({ returnValue: false, errorText: 'timed out' });
|
|
}, 4000);
|
|
|
|
function finish(payload) {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(timer);
|
|
callback(payload);
|
|
}
|
|
|
|
try {
|
|
service.call(uri, params || {}, function (message) {
|
|
finish((message && message.payload) || {});
|
|
});
|
|
} catch (err) {
|
|
finish({ returnValue: false, errorText: err.message });
|
|
}
|
|
}
|
|
|
|
/** Value that turns a setting off, or null when it is off/not a toggle. */
|
|
function disabledValueFor(value) {
|
|
if (value === true) return false;
|
|
if (typeof value === 'string') {
|
|
var normalized = value.toLowerCase();
|
|
if (normalized === 'on') return 'off';
|
|
if (normalized === 'true') return 'false';
|
|
if (normalized === 'yes') return 'no';
|
|
if (normalized === 'enable' || normalized === 'enabled') return 'disable';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function discoverSettings(callback) {
|
|
var found = [];
|
|
var index = 0;
|
|
|
|
function next() {
|
|
if (index >= SETTINGS_CATEGORIES.length) {
|
|
callback(found);
|
|
return;
|
|
}
|
|
var category = SETTINGS_CATEGORIES[index];
|
|
index += 1;
|
|
callLuna('luna://com.webos.settingsservice/getSystemSettings', { category: category }, function (payload) {
|
|
var settings = (payload && payload.settings) || {};
|
|
Object.keys(settings).forEach(function (key) {
|
|
if (!isUpdateKey(key)) return;
|
|
found.push({
|
|
category: category,
|
|
key: key,
|
|
value: settings[key],
|
|
canDisable: disabledValueFor(settings[key]) !== null
|
|
});
|
|
});
|
|
next();
|
|
});
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
function disableSettings(log, callback) {
|
|
discoverSettings(function (found) {
|
|
var targets = found.filter(function (entry) {
|
|
return entry.canDisable;
|
|
});
|
|
|
|
if (!found.length) {
|
|
log.add('no update-related system settings exposed by this firmware');
|
|
callback();
|
|
return;
|
|
}
|
|
if (!targets.length) {
|
|
log.add('update-related settings are already off: ' + found.map(function (e) {
|
|
return e.key + '=' + JSON.stringify(e.value);
|
|
}).join(', '));
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
var backup = readJson(SETTINGS_BACKUP_PATH, {});
|
|
targets.forEach(function (entry) {
|
|
var id = entry.category + '.' + entry.key;
|
|
if (!(id in backup)) backup[id] = entry.value;
|
|
});
|
|
try {
|
|
writeJson(SETTINGS_BACKUP_PATH, backup);
|
|
} catch (err) {
|
|
log.add('! could not save settings backup: ' + err.message);
|
|
}
|
|
|
|
var index = 0;
|
|
function next() {
|
|
if (index >= targets.length) {
|
|
callback();
|
|
return;
|
|
}
|
|
var entry = targets[index];
|
|
index += 1;
|
|
var settings = {};
|
|
settings[entry.key] = disabledValueFor(entry.value);
|
|
callLuna(
|
|
'luna://com.webos.settingsservice/setSystemSettings',
|
|
{ category: entry.category, settings: settings },
|
|
function (payload) {
|
|
if (payload && payload.returnValue) {
|
|
log.add('turned off ' + entry.category + '/' + entry.key +
|
|
' (' + JSON.stringify(entry.value) + ' -> ' + JSON.stringify(settings[entry.key]) + ')');
|
|
} else {
|
|
log.add('! could not change ' + entry.category + '/' + entry.key + ': ' +
|
|
((payload && payload.errorText) || 'unknown error'));
|
|
}
|
|
next();
|
|
}
|
|
);
|
|
}
|
|
next();
|
|
});
|
|
}
|
|
|
|
function restoreSettings(log, callback) {
|
|
var backup = readJson(SETTINGS_BACKUP_PATH, null);
|
|
if (!backup || !Object.keys(backup).length) {
|
|
log.add('no system settings to restore');
|
|
callback();
|
|
return;
|
|
}
|
|
var ids = Object.keys(backup);
|
|
var index = 0;
|
|
function next() {
|
|
if (index >= ids.length) {
|
|
try {
|
|
fs.unlinkSync(SETTINGS_BACKUP_PATH);
|
|
} catch (err) {
|
|
/* nothing to clean up */
|
|
}
|
|
callback();
|
|
return;
|
|
}
|
|
var id = ids[index];
|
|
index += 1;
|
|
var split = id.indexOf('.');
|
|
var category = id.slice(0, split);
|
|
var key = id.slice(split + 1);
|
|
var settings = {};
|
|
settings[key] = backup[id];
|
|
callLuna(
|
|
'luna://com.webos.settingsservice/setSystemSettings',
|
|
{ category: category, settings: settings },
|
|
function (payload) {
|
|
log.add((payload && payload.returnValue ? 'restored ' : '! could not restore ') +
|
|
category + '/' + key + ' = ' + JSON.stringify(backup[id]));
|
|
next();
|
|
}
|
|
);
|
|
}
|
|
next();
|
|
}
|
|
|
|
/* -------------------------------------------------------------- boot hook */
|
|
|
|
function bootHookScript(config, domains) {
|
|
return [
|
|
'#!/bin/sh',
|
|
'# LG Update Blocker boot hook - generated by ' + pkgInfo.name + ' v' + pkgInfo.version + '.',
|
|
'# Regenerated every time the settings are applied; delete it to disable.',
|
|
'',
|
|
'STATE=' + STATE_DIR,
|
|
'LOG=' + BOOT_LOG_PATH,
|
|
'HOSTS_TMP=' + HOSTS_TMP,
|
|
'WATCHER=' + WATCHER_PATH,
|
|
'BLOCK_HOSTS=' + (config.blockHosts ? 1 : 0),
|
|
'DISMISS_POPUP=' + (config.dismissPopup ? 1 : 0),
|
|
'PURGE_CACHE=' + (config.purgeCache ? 1 : 0),
|
|
'LOCK_CACHE=' + (config.lockCache ? 1 : 0),
|
|
'CACHE_DIRS="' + CACHE_DIRS.join(' ') + '"',
|
|
'',
|
|
'mkdir -p "$STATE"',
|
|
'if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt 65536 ]; then rm -f "$LOG"; fi',
|
|
'exec >>"$LOG" 2>&1',
|
|
'echo "--- $(date) LG Update Blocker ---"',
|
|
'',
|
|
'# First, because by now the popup has been on screen for ~20 seconds: the',
|
|
'# updater runs its version check from webos-mbd.target, long before the',
|
|
'# Homebrew Channel gets round to running these hooks.',
|
|
'if [ "$DISMISS_POPUP" = 1 ]; then',
|
|
' id=$(sed -n \'s/.*_gAlertWindowId : \\(' + ALERT_ID_PATTERN + '\\).*/\\1/p\' \\',
|
|
' ' + UPDATE_DAEMON_LOG + ' 2>/dev/null | tail -1)',
|
|
' if [ -n "$id" ] && luna-send -t 1 -f luna://com.webos.notification/closeAlert \\',
|
|
' "{\\"alertId\\":\\"$id\\"}" >/dev/null 2>&1; then',
|
|
' echo "dismissed update popup $id"',
|
|
' fi',
|
|
' # and stay subscribed for the rest of the session in case it comes back',
|
|
' if [ -x "$WATCHER" ]; then',
|
|
' if command -v setsid >/dev/null 2>&1; then',
|
|
' setsid "$WATCHER" >/dev/null 2>&1 </dev/null &',
|
|
' else',
|
|
' "$WATCHER" >/dev/null 2>&1 </dev/null &',
|
|
' fi',
|
|
' echo "alert watcher started"',
|
|
' fi',
|
|
'fi',
|
|
'',
|
|
'if [ "$BLOCK_HOSTS" = 1 ] && [ -f "$STATE/hosts.txt" ]; then',
|
|
' if ! (: >> /etc/hosts) 2>/dev/null; then',
|
|
' cp /etc/hosts "$HOSTS_TMP" && chmod 644 "$HOSTS_TMP" \\',
|
|
' && mount --bind "$HOSTS_TMP" /etc/hosts \\',
|
|
' && echo "bind-mounted writable /etc/hosts"',
|
|
' fi',
|
|
' if grep -q "lg-update-blocker" /etc/hosts 2>/dev/null; then',
|
|
' echo "hosts entries already present"',
|
|
' elif (: >> /etc/hosts) 2>/dev/null; then',
|
|
' {',
|
|
' echo "' + MARKER_BEGIN + '"',
|
|
' sed -e "s/#.*//" -e "s/[[:space:]]*$//" -e "/^$/d" "$STATE/hosts.txt" | while read -r domain; do',
|
|
' echo "127.0.0.1 $domain"',
|
|
' echo "::1 $domain"',
|
|
' done',
|
|
' echo "' + MARKER_END + '"',
|
|
' } >> /etc/hosts && echo "hosts entries added"',
|
|
' else',
|
|
' echo "could not make /etc/hosts writable"',
|
|
' fi',
|
|
'fi',
|
|
'',
|
|
'if [ "$PURGE_CACHE" = 1 ]; then',
|
|
' for dir in $CACHE_DIRS; do',
|
|
' [ -d "$dir" ] || continue',
|
|
' case "$dir" in /mnt/lg/*) ;; *) continue ;; esac',
|
|
' entries=$(ls -A "$dir" 2>/dev/null)',
|
|
' [ -n "$entries" ] || continue',
|
|
' rm -rf "$dir"/* "$dir"/.[!.]* 2>/dev/null',
|
|
' echo "purged staged item(s) from $dir: $(echo "$entries" | tr \'\\n\' \' \')"',
|
|
' done',
|
|
'fi',
|
|
'',
|
|
'if [ "$LOCK_CACHE" = 1 ]; then',
|
|
' mkdir -p "$STATE/empty"',
|
|
' for dir in $CACHE_DIRS; do',
|
|
' [ -d "$dir" ] || continue',
|
|
' case "$dir" in /mnt/lg/*) ;; *) continue ;; esac',
|
|
' if awk \'{print $2}\' /proc/mounts | grep -qx "$dir"; then',
|
|
' echo "$dir already locked"',
|
|
' continue',
|
|
' fi',
|
|
' if mount -o bind,ro "$STATE/empty" "$dir"; then',
|
|
' mount -o bind,remount,ro "$STATE/empty" "$dir" 2>/dev/null',
|
|
' echo "locked $dir"',
|
|
' else',
|
|
' echo "could not lock $dir"',
|
|
' fi',
|
|
' done',
|
|
'fi',
|
|
'',
|
|
'echo "done"',
|
|
''
|
|
].join('\n');
|
|
}
|
|
|
|
/* Long-lived companion to the boot hook: subscribes to the notification
|
|
* manager and closes update alerts as they open. Kept as a separate file so
|
|
* the hook itself stays a short, ordinary run-parts script. */
|
|
function watcherScript() {
|
|
return [
|
|
'#!/bin/sh',
|
|
'# LG Update Blocker alert watcher - generated by ' + pkgInfo.name + ' v' + pkgInfo.version + '.',
|
|
'# Closes LG\'s "a new software version is available" alert as it opens.',
|
|
'',
|
|
'LOG=' + BOOT_LOG_PATH,
|
|
'PIDFILE=' + WATCHER_PID_PATH,
|
|
'',
|
|
'echo $$ > "$PIDFILE"',
|
|
'# the INT/TERM handler has to exit explicitly: a trap that just returns',
|
|
'# resumes the script, which would then re-subscribe and outlive the kill',
|
|
'trap \'rm -f "$PIDFILE"\' EXIT',
|
|
'trap \'rm -f "$PIDFILE"; exit 0\' INT TERM',
|
|
'',
|
|
'note() { echo "$(date) $*" >> "$LOG"; }',
|
|
'',
|
|
'close_alert() {',
|
|
' [ -n "$1" ] || return 1',
|
|
' luna-send -t 1 -f luna://com.webos.notification/closeAlert \\',
|
|
' "{\\"alertId\\":\\"$1\\"}" >/dev/null 2>&1',
|
|
'}',
|
|
'',
|
|
'# getAlertNotification only pushes to clients that were already subscribed,',
|
|
'# so it never reports the popup that is on screen right now - and',
|
|
'# closeAllAlerts rejects every source id it accepts arguments for. The',
|
|
'# subscription below therefore only covers alerts raised from now on; the',
|
|
'# boot hook handles the one already open, using the updater\'s own log.',
|
|
'while :; do',
|
|
' luna-send -i -f luna://com.webos.notification/getAlertNotification \\',
|
|
' \'{"subscribe":true}\' 2>/dev/null |',
|
|
' while read -r line; do',
|
|
' case "$line" in',
|
|
' *com.webos.service.update-*) ;;',
|
|
' *) continue ;;',
|
|
' esac',
|
|
' id=$(echo "$line" | sed -n \'s/.*\\(' + ALERT_ID_PATTERN + '\\).*/\\1/p\')',
|
|
' close_alert "$id" || continue',
|
|
' # one line per popup, not per notification the bus repeats',
|
|
' [ "$id" = "$last" ] && continue',
|
|
' last=$id',
|
|
' note "dismissed update popup $id"',
|
|
' done',
|
|
' # the bus dropped us; wait before re-subscribing rather than spinning',
|
|
' sleep 30',
|
|
'done',
|
|
''
|
|
].join('\n');
|
|
}
|
|
|
|
function installBootHook(config, log) {
|
|
var domains = readDomains();
|
|
mkdirp(STATE_DIR);
|
|
mkdirp(BOOT_HOOK_DIR);
|
|
fs.writeFileSync(HOSTS_LIST_PATH, domains.join('\n') + '\n');
|
|
fs.writeFileSync(WATCHER_PATH, watcherScript());
|
|
fs.chmodSync(WATCHER_PATH, parseInt('755', 8));
|
|
fs.writeFileSync(BOOT_HOOK_PATH, bootHookScript(config, domains));
|
|
fs.chmodSync(BOOT_HOOK_PATH, parseInt('755', 8));
|
|
log.add('boot hook installed at ' + BOOT_HOOK_PATH);
|
|
|
|
/* versions before 1.1 installed the hook under the bare slug, which
|
|
* run-parts would then run a second time */
|
|
if (exists(LEGACY_BOOT_HOOK_PATH)) {
|
|
try {
|
|
fs.unlinkSync(LEGACY_BOOT_HOOK_PATH);
|
|
log.add('removed old boot hook ' + LEGACY_BOOT_HOOK_PATH);
|
|
} catch (err) {
|
|
log.add('! could not remove old boot hook: ' + err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeBootHook(log) {
|
|
var removed = [BOOT_HOOK_PATH, LEGACY_BOOT_HOOK_PATH].filter(function (target) {
|
|
if (!exists(target)) return false;
|
|
try {
|
|
fs.unlinkSync(target);
|
|
return true;
|
|
} catch (err) {
|
|
log.add('! could not remove boot hook: ' + err.message);
|
|
return false;
|
|
}
|
|
});
|
|
log.add(removed.length ? 'removed boot hook ' + removed.join(', ') : 'no boot hook installed');
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- methods */
|
|
|
|
function baseStatus() {
|
|
return {
|
|
version: pkgInfo.version,
|
|
uid: typeof process.getuid === 'function' ? process.getuid() : -1,
|
|
root: isRoot(),
|
|
config: readConfig(),
|
|
hosts: {
|
|
path: HOSTS_PATH,
|
|
writable: hostsWritable(),
|
|
bindMounted: isMountPoint(HOSTS_PATH),
|
|
blockedDomains: hostsBlockedDomains(),
|
|
totalDomains: readDomains().length
|
|
},
|
|
cache: cacheStatus(),
|
|
popup: {
|
|
lastAlertId: lastAlertId(),
|
|
watcherPid: watcherPid()
|
|
},
|
|
bootHook: {
|
|
path: BOOT_HOOK_PATH,
|
|
installed: exists(BOOT_HOOK_PATH)
|
|
}
|
|
};
|
|
}
|
|
|
|
function respondError(message, text) {
|
|
message.respond({ returnValue: false, errorText: text });
|
|
}
|
|
|
|
service.register('status', function (message) {
|
|
var status = baseStatus();
|
|
discoverSettings(function (settings) {
|
|
status.settings = settings;
|
|
status.returnValue = true;
|
|
message.respond(status);
|
|
});
|
|
});
|
|
|
|
service.register('apply', function (message) {
|
|
if (!isRoot()) {
|
|
respondError(message, 'Service is not running as root - elevate it through the Homebrew Channel first.');
|
|
return;
|
|
}
|
|
|
|
var log = new Log();
|
|
var config = mergeConfig(readConfig(), message.payload || {});
|
|
|
|
try {
|
|
mkdirp(STATE_DIR);
|
|
writeJson(CONFIG_PATH, config);
|
|
|
|
if (config.blockHosts) {
|
|
applyHosts(log);
|
|
} else {
|
|
removeHosts(log);
|
|
}
|
|
|
|
if (config.purgeCache) purgeCache(log);
|
|
|
|
if (config.lockCache) {
|
|
lockCache(log);
|
|
} else {
|
|
unlockCache(log);
|
|
}
|
|
|
|
if (anyEnabled(config)) {
|
|
installBootHook(config, log);
|
|
} else {
|
|
removeBootHook(log);
|
|
}
|
|
} catch (err) {
|
|
log.add('! ' + err.message);
|
|
message.respond({ returnValue: false, errorText: err.message, log: log.lines, config: config });
|
|
return;
|
|
}
|
|
|
|
function finish() {
|
|
log.add('done - reboot the TV to verify the popup is gone');
|
|
message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() });
|
|
}
|
|
|
|
function afterSettings() {
|
|
if (config.dismissPopup) {
|
|
dismissPopup(log, finish);
|
|
} else {
|
|
if (stopWatcher()) log.add('alert watcher stopped');
|
|
finish();
|
|
}
|
|
}
|
|
|
|
if (config.disableSettings) {
|
|
disableSettings(log, afterSettings);
|
|
} else {
|
|
restoreSettings(log, afterSettings);
|
|
}
|
|
});
|
|
|
|
service.register('revert', function (message) {
|
|
if (!isRoot()) {
|
|
respondError(message, 'Service is not running as root - elevate it through the Homebrew Channel first.');
|
|
return;
|
|
}
|
|
|
|
var log = new Log();
|
|
var config = {};
|
|
CONFIG_KEYS.forEach(function (key) {
|
|
config[key] = false;
|
|
});
|
|
|
|
try {
|
|
removeHosts(log);
|
|
unlockCache(log);
|
|
if (stopWatcher()) log.add('alert watcher stopped');
|
|
removeBootHook(log);
|
|
mkdirp(STATE_DIR);
|
|
writeJson(CONFIG_PATH, config);
|
|
} catch (err) {
|
|
log.add('! ' + err.message);
|
|
}
|
|
|
|
restoreSettings(log, function () {
|
|
log.add('everything reverted - the update popup will be back on the next boot');
|
|
message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() });
|
|
});
|
|
});
|
|
|
|
service.register('purge', function (message) {
|
|
if (!isRoot()) {
|
|
respondError(message, 'Service is not running as root - elevate it through the Homebrew Channel first.');
|
|
return;
|
|
}
|
|
var log = new Log();
|
|
purgeCache(log);
|
|
message.respond({ returnValue: true, log: log.lines, status: baseStatus() });
|
|
});
|
|
|
|
service.register('diagnostics', function (message) {
|
|
var status = baseStatus();
|
|
discoverSettings(function (settings) {
|
|
message.respond({
|
|
returnValue: true,
|
|
status: status,
|
|
settings: settings,
|
|
osInfo: readJson('/var/run/nyx/os_info.json', null),
|
|
hostsFile: readFile(HOSTS_PATH, '(unreadable)').split('\n').slice(-40).join('\n'),
|
|
bootLog: readFile(BOOT_LOG_PATH, '(no boot log yet)').split('\n').slice(-40).join('\n'),
|
|
updateLog: readFile(UPDATE_DAEMON_LOG, '(no update log)').split('\n').slice(-40).join('\n'),
|
|
mounts: mountTargets().filter(function (target) {
|
|
return target === HOSTS_PATH || target.indexOf('/mnt/lg') === 0;
|
|
})
|
|
});
|
|
});
|
|
});
|
|
|
|
console.info(pkgInfo.name + ' v' + pkgInfo.version + ' started, uid=' +
|
|
(typeof process.getuid === 'function' ? process.getuid() : 'unknown'));
|