feat: stop the updater being launched at all

Closing the popup after the fact left it on screen for the 15-20 seconds
between the updater's version check and the Homebrew Channel running its
init.d hooks. That race cannot be won, so remove it.

/usr/sbin/update is launched on demand by ls-hubd via a manifest, and
ls-hubd.conf lists ManifestsVolatileDirectories under /var - writable,
persistent, and how webOS itself ships manifest updates. A manifest there
with the same id and a higher version replaces the read-only one, so drop
in a copy that keeps every role and permission file and only empties
serviceFiles. Nothing on the bus can start the updater after that: no
version check, no alert, nothing to dismiss. Callers get an immediate
"Service does not exist" rather than a hang, and deleting the file undoes it.

Verified on a CX (webOS 5, 04.60.65) across reboots: no update process,
/tmp/var/log/update.log never created, no alert. Previously that log was
55 kB with two server checks and an _gAlertWindowId per boot. Revert puts
the updater back on the bus.

The manifest, its D-Bus service file and the updater binary are all
discovered from ls-hubd.conf rather than hardcoded. The popup-closing
layer stays as a fallback for when this one is off.

Also tried and rejected, now documented in the README: update-related
settings, masking update.service (no writable unit directory), and
breaking its ping through /var/systemd/system/env/update.env - the ping
does fail, but something else on the bus activates the updater anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rene Kievits
2026-09-06 02:18:01 +02:00
co-authored by Claude Opus 5
parent ef8b6b96c4
commit 6fa8ad62f6
5 changed files with 374 additions and 67 deletions
+74 -34
View File
@@ -19,8 +19,7 @@ Blocking `snu.lge.com` in `/etc/hosts` is the obvious fix, and it is what the
Homebrew Channel's own *Block system updates* toggle does. On a CX it does not Homebrew Channel's own *Block system updates* toggle does. On a CX it does not
stop the boot popup, and it took a session on the TV to work out why. stop the boot popup, and it took a session on the TV to work out why.
`/usr/sbin/update` is started by **systemd** (`update.service`, pulled in by The updater runs its version check against
`webos-mbd.target`) and runs its version check against
`https://snu.lge.com/CheckSWAutoUpdate.laf` roughly **20 seconds before** the `https://snu.lge.com/CheckSWAutoUpdate.laf` roughly **20 seconds before** the
Homebrew Channel gets as far as running its `init.d` hooks. So on every boot Homebrew Channel gets as far as running its `init.d` hooks. So on every boot
the check reaches a real LG server, gets `RESULT_CD 900 / Success` back, and the check reaches a real LG server, gets `RESULT_CD 900 / Success` back, and
@@ -32,29 +31,55 @@ the popup is already on screen by the time any hosts entry exists:
00:21:11 lg-update-blocker: hosts entries added ← 19 seconds too late 00:21:11 lg-update-blocker: hosts entries added ← 19 seconds too late
``` ```
Losing that race is not fixable from userspace. Every directory systemd reads That race cannot be won — nothing unprivileged runs earlier than those hooks —
units from is a read-only overlay except `/run/systemd/system`, which is tmpfs so the app removes it instead.
and created after the updater has already gone. Nor is there a setting that
gates the check — `automaticUpdate`, `support/softwareUpdateEnable`,
`hotelMode/swUpdateEnable` and the `.UpdateIsInprogress` flag were each tested
by restarting the daemon and counting the requests it made, and none of them
stop it.
So this app **closes the popup** instead of trying to prevent it, and blocks `/usr/sbin/update` is not a normal daemon you can stop. It is launched on
the hostnames to stop the download and every later check in the session. Two demand by **ls-hubd**, which finds it through a *manifest*: a small JSON file
things also turned out not to be true, and the app no longer pretends listing the D-Bus service file that carries `Exec=/usr/sbin/update`. LG's own
otherwise: the popup is not caused by a staged firmware image (the staging manifest is on a read-only partition, but `/etc/luna-service2/ls-hubd.conf`
directory is empty at boot; the size the daemon reports is in-memory only), and also lists `ManifestsVolatileDirectories` under `/var` — writable, persistent,
it is not caused by an update *service* that can be stopped — `/etc/init` is and the mechanism webOS itself uses to ship manifest updates. A manifest there
dead upstart leftovers on a systemd TV, and `initctl stop update` silently with the **same id and a higher version replaces** the read-only one.
"succeeds" while doing nothing.
So the app drops in a copy that keeps every role and permission file exactly as
it was and only empties the `serviceFiles` list. That single change takes away
ls-hubd's ability to launch the updater, and nothing on the bus can start it
any more: no version check, no alert, nothing to dismiss. Callers get an
immediate `Service does not exist` rather than hanging, and deleting the one
file puts everything back.
Measured on a CX, before and after: `/tmp/var/log/update.log` used to be 55 kB
with two server checks and an `_gAlertWindowId` per boot — now the file is never
created at all.
### Things that do not work
Each of these was tried on the TV before settling on the above, and none of
them stop the popup:
* **Settings.** `automaticUpdate`, `support/softwareUpdateEnable`,
`hotelMode/swUpdateEnable` and the `.UpdateIsInprogress` flag were each tested
by restarting the daemon and counting the requests it made. It checks anyway.
* **Masking the systemd unit.** `update.service` does ping the updater at boot,
but every directory systemd reads units from is a read-only overlay except
`/run/systemd/system`, which is tmpfs and created too late.
* **Breaking that ping.** `update.service` reads
`EnvironmentFile=-/var/systemd/system/env/update.env`, which *is* writable, and
pointing `LS_HUB_LOCAL_SOCKET_DIRECTORY` at a bogus path does make the ping
fail. The updater still gets activated — something else on the bus asks for it.
* **Stopping a service.** `/etc/init` is dead upstart leftovers on a systemd TV;
`initctl stop update` silently "succeeds" while doing nothing.
* **Deleting a staged image.** The popup is not caused by one — the staging
directory is empty at boot, and the size the daemon reports is in-memory only.
## Protection layers ## Protection layers
| Layer | What it does | Default | | Layer | What it does | Default |
| --- | --- | --- | | --- | --- | --- |
| **Stop the updater running** | Overrides LG's ls-hubd manifest with one that has no `serviceFiles`, so `com.webos.service.update` can never be launched. This is the layer that actually stops the popup | on |
| **Block LG update servers** | Points `snu`/`su`/`nsu.lge.com`, their TLS variants, the `-dev` fallbacks and the CDN aliases at `127.0.0.1` in `/etc/hosts` | on | | **Block LG update servers** | Points `snu`/`su`/`nsu.lge.com`, their TLS variants, the `-dev` fallbacks and the CDN aliases at `127.0.0.1` in `/etc/hosts` | on |
| **Close the update popup** | Closes the alert the updater raised during boot, then stays subscribed to `com.webos.notification` and closes any later one as it opens | on | | **Close the update popup** | Fallback for a popup that appears anyway: closes the alert the updater raised during boot, then stays subscribed to `com.webos.notification` and closes any later one as it opens | on |
| **Delete staged firmware** | Wipes the update image staged in `/mnt/lg/cmn_data/swupdate` if the TV ever does download one | on | | **Delete staged firmware** | Wipes the update image staged in `/mnt/lg/cmn_data/swupdate` if the TV ever does download one | on |
| **Lock the staging folder** | Bind-mounts an empty read-only directory over it, so nothing can be staged again | off | | **Lock the staging folder** | Bind-mounts an empty read-only directory over it, so nothing can be staged again | off |
| **Turn off auto-update settings** | Reads every `com.webos.settingsservice` category, switches off each update-related key it finds, and remembers the original values. On a CX this includes `hotelMode/swUpdateEnable`, which also hides the *Software Update* menu until you revert | on | | **Turn off auto-update settings** | Reads every `com.webos.settingsservice` category, switches off each update-related key it finds, and remembers the original values. On a CX this includes `hotelMode/swUpdateEnable`, which also hides the *Software Update* menu until you revert | on |
@@ -62,13 +87,24 @@ dead upstart leftovers on a systemd TV, and `initctl stop update` silently
Every layer is undone by **Remove protection**, which also restores the Every layer is undone by **Remove protection**, which also restores the
settings from the backup it made. settings from the backup it made.
Be honest about what this buys you: the popup is closed within a fraction of a With *Stop the updater running* on, the popup does not appear at all — there is
second of the Homebrew Channel starting its hooks, but the updater raised it nothing to close, because the version check never happens. That layer lives on
15–20 seconds earlier, so on a cold boot **you will see it appear and then a persistent partition, so it also survives a reboot without help; the boot hook
vanish by itself**. It is gone before you can act on it, and you never have to only puts it back if something (a firmware update, an app install that rewrote
dismiss it. The hook is installed as `00-lgupdateblocker` so `run-parts` runs the volatile manifest directory) removed it.
it before the other homebrew hooks, which is as early as anything unprivileged
can go. Measured on a CX: alert raised at `01:26:22`, closed at `01:26:37`. If you turn that layer off and rely on *Close the update popup* alone, the
popup is closed within a fraction of a second of the Homebrew Channel starting
its hooks — but the updater raised it 15–20 seconds earlier, so on a cold boot
**you will see it appear and then vanish by itself**. The hook is installed as
`00-lgupdateblocker` so `run-parts` runs it before the other homebrew hooks,
which is as early as anything unprivileged can go.
Expect the *Software Update* screen in Settings to stop working while this is
on — it queries the service that is now off the bus, and will get an error
instead of an answer. On a CX the *Turn off auto-update settings* layer already
hides that menu anyway (`hotelMode/swUpdateEnable`). *Remove protection* brings
both back.
## Requirements ## Requirements
@@ -119,6 +155,7 @@ Other buttons: **Purge staged update** (wipe a downloaded image right now),
| Path | Purpose | | Path | Purpose |
| --- | --- | | --- | --- |
| `/var/luna-service2/manifests.d/update.json` | The manifest override that takes the updater off the bus. Same id and role files as LG's, higher version, empty `serviceFiles`. Written only if nothing else is there, and only removed again if it is still ours |
| `/etc/hosts` | Blocked hostnames, between `# >>> lg-update-blocker >>>` markers. Bind-mounted from `/tmp/lgupdateblocker-hosts` because `/` is read-only | | `/etc/hosts` | Blocked hostnames, between `# >>> lg-update-blocker >>>` markers. Bind-mounted from `/tmp/lgupdateblocker-hosts` because `/` is read-only |
| `/var/lib/webosbrew/init.d/00-lgupdateblocker` | Boot hook, run by the Homebrew Channel startup script; re-applies the enabled layers. The `00-` prefix makes `run-parts` run it first | | `/var/lib/webosbrew/init.d/00-lgupdateblocker` | Boot hook, run by the Homebrew Channel startup script; re-applies the enabled layers. The `00-` prefix makes `run-parts` run it first |
| `/var/lib/webosbrew/lgupdateblocker/` | `config.json`, `hosts.txt`, `settings-backup.json`, `boot.log`, `alert-watch.sh` | | `/var/lib/webosbrew/lgupdateblocker/` | `config.json`, `hosts.txt`, `settings-backup.json`, `boot.log`, `alert-watch.sh` |
@@ -133,15 +170,18 @@ replaced.
* **Badge says "no root"** — open the Homebrew Channel, verify *Root status* is * **Badge says "no root"** — open the Homebrew Channel, verify *Root status* is
`ok`, then relaunch this app. If the Homebrew Channel itself is unelevated, `ok`, then relaunch this app. If the Homebrew Channel itself is unelevated,
fix that first (its Settings screen has a button for it). fix that first (its Settings screen has a button for it).
* **Popup still appears after a reboot** — run *Diagnostics* and look at the * **Popup still appears after a reboot** — run *Diagnostics* and check
boot-hook log at the bottom. It records, per boot, whether the hosts entries `updater` in the status block. `blocked` should be `true` and `running`
were added, what was purged, and the id of the popup it closed. If there is `false`; if `found` is `false`, ls-hubd on your firmware locates the updater
no `dismissed update popup …` line, check `updateLog` in the same dump for some other way and the diagnostics dump is what to open an issue with. If it
`_gAlertWindowId` — if that is missing too, your TV raises the alert by some says `blocked: true` and the popup still shows up, `updateLog` in the same
other route and the diagnostics dump is what to open an issue with. dump tells you whether the updater ran at all — on a working setup
* **The popup appears for ~15 seconds, then disappears on its own** — expected, see `/tmp/var/log/update.log` does not exist.
[Why blocking the update servers is not enough](#why-blocking-the-update-servers-is-not-enough). * **The popup appears for ~15 seconds, then disappears on its own** — that is
Nothing running after the Homebrew Channel can beat the updater to it. the *Close the update popup* fallback doing its job because the updater is
still running. Make sure *Stop the updater running* is ticked and applied.
* **Software Update in Settings shows an error** — expected while the updater
layer is on; see [Protection layers](#protection-layers).
* **Belt and braces** — a hosts file only helps if the TV uses DNS. Blocking * **Belt and braces** — a hosts file only helps if the TV uses DNS. Blocking
`snu.lge.com`, `su.lge.com` and `nsu.lge.com` on your router or Pi-hole as `snu.lge.com`, `su.lge.com` and `nsu.lge.com` on your router or Pi-hole as
well is the one measure that also survives a factory reset. well is the one measure that also survives a factory reset.
+14 -1
View File
@@ -13,6 +13,11 @@
'/media/developer/apps/usr/palm/services/org.webosbrew.hbchannel.service/elevate-service'; '/media/developer/apps/usr/palm/services/org.webosbrew.hbchannel.service/elevate-service';
var TOGGLES = [ var TOGGLES = [
{
key: 'blockUpdater',
title: 'Stop the updater running',
desc: 'Takes /usr/sbin/update off the bus, so no version check happens and no popup appears'
},
{ {
key: 'blockHosts', key: 'blockHosts',
title: 'Block LG update servers', title: 'Block LG update servers',
@@ -21,7 +26,7 @@
{ {
key: 'dismissPopup', key: 'dismissPopup',
title: 'Close the update popup', title: 'Close the update popup',
desc: 'The TV asks LG about updates ~20s before homebrew starts, so the popup is closed instead' desc: 'Fallback: closes a popup that appears anyway, e.g. with the updater left running'
}, },
{ {
key: 'purgeCache', key: 'purgeCache',
@@ -49,6 +54,7 @@
]; ];
var config = { var config = {
blockUpdater: true,
blockHosts: true, blockHosts: true,
dismissPopup: true, dismissPopup: true,
purgeCache: true, purgeCache: true,
@@ -224,6 +230,13 @@
}); });
row('Service user', status.root ? 'root' : 'uid ' + status.uid + ' (not elevated)'); row('Service user', status.root ? 'root' : 'uid ' + status.uid + ' (not elevated)');
var updater = status.updater || {};
if (!updater.found) {
row('Update service', 'not present on this TV');
} else {
row('Update service', (updater.blocked ? 'disabled' : 'launchable') +
(updater.running ? ', running now' : ', not running'));
}
row('Hosts entries', status.hosts.blockedDomains + ' / ' + status.hosts.totalDomains + row('Hosts entries', status.hosts.blockedDomains + ' / ' + status.hosts.totalDomains +
(status.hosts.bindMounted ? ' (bind-mounted)' : '')); (status.hosts.bindMounted ? ' (bind-mounted)' : ''));
row('Staging folders', stagedDirs ? stagedDirs + ' found' : 'none on this TV'); row('Staging folders', stagedDirs ? stagedDirs + ' found' : 'none on this TV');
+16 -7
View File
@@ -4,19 +4,24 @@ that webOS shows on every boot, without touching the firmware itself.</p>
<p>Blocking the update servers alone does not do it: the updater asks LG about <p>Blocking the update servers alone does not do it: the updater asks LG about
new firmware around 20 seconds before the Homebrew Channel gets to run its boot new firmware around 20 seconds before the Homebrew Channel gets to run its boot
hooks, so on a CX the check succeeds every time and the popup is already up. hooks, so on a CX the check succeeds every time and the popup is already up.
Nothing running that late can win the race, so this app closes the popup as Nothing running that late can win that race, so this app removes it instead -
well as blocking the servers.</p> it takes the updater off the bus entirely, and the check never happens.</p>
<p>It applies up to five reversible layers and re-applies them on every boot <p>It applies up to six reversible layers and re-applies them on every boot
through a Homebrew Channel startup hook:</p> through a Homebrew Channel startup hook:</p>
<ul> <ul>
<li><b>Stop the updater running</b> - webOS launches /usr/sbin/update on
demand from an ls-hubd manifest. This drops a higher-version manifest into
the writable directory ls-hubd already scans, identical to LG's except
that it lists no service files, so the updater can no longer be launched:
no version check, no popup, nothing to dismiss.</li>
<li><b>Block LG update servers</b> - points snu/su/nsu.lge.com and their TLS <li><b>Block LG update servers</b> - points snu/su/nsu.lge.com and their TLS
and CDN aliases at 127.0.0.1 in /etc/hosts, which stops the download and and CDN aliases at 127.0.0.1 in /etc/hosts, which stops the download and
every later check in the session.</li> every later check in the session.</li>
<li><b>Close the update popup</b> - closes the alert raised during boot, then <li><b>Close the update popup</b> - fallback for a popup that appears anyway:
stays subscribed to the notification manager and closes any later one as closes the alert raised during boot, then stays subscribed to the
it opens.</li> notification manager and closes any later one as it opens.</li>
<li><b>Delete staged firmware</b> - wipes /mnt/lg/cmn_data/swupdate should the <li><b>Delete staged firmware</b> - wipes /mnt/lg/cmn_data/swupdate should the
TV ever manage to download an image.</li> TV ever manage to download an image.</li>
<li><b>Lock the staging folder</b> - bind-mounts an empty read-only directory <li><b>Lock the staging folder</b> - bind-mounts an empty read-only directory
@@ -27,4 +32,8 @@ through a Homebrew Channel startup hook:</p>
</ul> </ul>
<p>Requires root and the Homebrew Channel: the app asks the Homebrew Channel to <p>Requires root and the Homebrew Channel: the app asks the Homebrew Channel to
elevate its own service on first launch. "Remove protection" undoes everything.</p> elevate its own service on first launch. "Remove protection" undoes everything,
including putting the updater back on the bus.</p>
<p>While the updater layer is on, expect the Software Update screen in Settings
to show an error - it queries the service that is now gone.</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "com.rkievits.lgupdateblocker", "name": "com.rkievits.lgupdateblocker",
"version": "1.1.0", "version": "1.2.0",
"title": "LG Update Blocker", "title": "LG Update Blocker",
"description": "Blocks the recurring LG webOS firmware update popup on rooted TVs", "description": "Blocks the recurring LG webOS firmware update popup on rooted TVs",
"private": true, "private": true,
+269 -24
View File
@@ -3,29 +3,44 @@
* *
* Everything this service does is reversible and is applied in layers: * Everything this service does is reversible and is applied in layers:
* *
* 1. hosts - point LG's firmware update servers at 127.0.0.1 * 1. updater - stop /usr/sbin/update from ever being launched
* 2. dismiss - close the "software update available" alert at boot * 2. hosts - point LG's firmware update servers at 127.0.0.1
* 3. purge - delete the firmware image the TV already staged * 3. dismiss - close the "software update available" alert at boot
* 4. lock - bind-mount an empty read-only dir over the staging dir * 4. purge - delete the firmware image the TV already staged
* 5. settings - switch off the update-related com.webos.settingsservice keys * 5. lock - bind-mount an empty read-only dir over the staging dir
* 6. 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 * / 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 * every boot, so the layers that live there are re-applied by a boot hook
* /var/lib/webosbrew/init.d (run by the Homebrew Channel startup script). * 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 * Why the hosts block alone is not enough: /usr/sbin/update runs its version
* systemd (webos-mbd.target) and runs its version check against snu.lge.com * check against snu.lge.com roughly 20 seconds before the Homebrew Channel
* roughly 20 seconds before the Homebrew Channel gets as far as running the * gets as far as running the hooks in init.d. On a CX that check therefore
* hooks in init.d. On a CX that check therefore *succeeds* on every boot and * *succeeds* on every boot and the popup is already on screen by the time
* the popup is already on screen by the time anything of ours runs - which is * anything of ours runs - which is also why the Homebrew Channel's own "block
* also why the Homebrew Channel's own "block system updates" toggle does not * system updates" toggle does not stop it, and why closing the popup after the
* stop it. Nothing persistent gates that check (automaticUpdate, * fact leaves it visible for those 20 seconds.
* support/softwareUpdateEnable, hotelMode/swUpdateEnable and the *
* .UpdateIsInprogress flag were all measured to make no difference), and there * The updater layer removes the race instead of trying to win it. The updater
* is no writable directory early enough in the boot to win the race: every * is not a normal daemon: it is launched on demand by ls-hubd from a D-Bus
* systemd unit path except /run is a read-only overlay. So the popup is closed * service file, which ls-hubd finds through a manifest. ls-hubd.conf lists
* instead of prevented, and the hosts block stops the download and every * ManifestsVolatileDirectories under /var - writable, persistent, and how
* later check. * webOS itself ships manifest updates - and a manifest there with the same id
* and a higher version replaces the read-only one. Dropping in a copy whose
* serviceFiles list is empty leaves every role and permission file in place
* but removes the launch entry, so nothing on the bus can start the updater:
* no version check, no alert, nothing to dismiss. Callers get an immediate
* "Service does not exist" instead of hanging, and deleting the one file puts
* it all back.
*
* Things that do *not* work, all measured on a CX before settling on the
* above: automaticUpdate, support/softwareUpdateEnable, hotelMode/swUpdateEnable
* and the .UpdateIsInprogress flag change nothing; no systemd unit directory is
* writable, so update.service cannot be masked; breaking update.service's ping
* via its /var/systemd/system/env override does make that ping fail, but
* something else on the bus activates the updater anyway.
* *
* Written in ES5 without dependencies - it runs on the TV's own node with the * Written in ES5 without dependencies - it runs on the TV's own node with the
* platform-provided webos-service module and nothing else. * platform-provided webos-service module and nothing else.
@@ -84,6 +99,15 @@ var CACHE_GUARD = '/mnt/lg/';
var UPDATE_DAEMON_LOG = '/tmp/var/log/update.log'; var UPDATE_DAEMON_LOG = '/tmp/var/log/update.log';
var ALERT_ID_PATTERN = 'com\\.webos\\.service\\.update-[0-9]*'; var ALERT_ID_PATTERN = 'com\\.webos\\.service\\.update-[0-9]*';
/* ls-hubd launches the updater on demand; its config says which manifest
* directories are read-only and which are the writable ones we may override
* from. Everything about the updater - its manifest, its D-Bus service file,
* even the path of its binary - is discovered from there rather than assumed,
* so this works the same on a firmware that moves them. */
var LS_HUBD_CONF = '/etc/luna-service2/ls-hubd.conf';
var UPDATER_SERVICE = 'com.webos.service.update';
var UPDATER_BINARY_FALLBACK = '/usr/sbin/update';
var SETTINGS_CATEGORIES = ['option', 'general', 'network', 'commercial', 'support', 'hotelMode']; var SETTINGS_CATEGORIES = ['option', 'general', 'network', 'commercial', 'support', 'hotelMode'];
var SETTINGS_KEY_PATTERN = /(update|upgrade|firmware)/i; var SETTINGS_KEY_PATTERN = /(update|upgrade|firmware)/i;
/* "ota" and "nsu" are substrings of perfectly innocent keys - screenRotation, /* "ota" and "nsu" are substrings of perfectly innocent keys - screenRotation,
@@ -96,6 +120,7 @@ function isUpdateKey(key) {
} }
var DEFAULT_CONFIG = { var DEFAULT_CONFIG = {
blockUpdater: true,
blockHosts: true, blockHosts: true,
dismissPopup: true, dismissPopup: true,
purgeCache: true, purgeCache: true,
@@ -202,6 +227,185 @@ function anyEnabled(config) {
}); });
} }
/* ---------------------------------------------------------------- updater */
/** Semicolon-separated directory list out of ls-hubd.conf. */
function lsHubdDirs(key) {
var match = readFile(LS_HUBD_CONF, '').match(new RegExp('^[ \\t]*' + key + '[ \\t]*=(.*)$', 'm'));
if (!match) return [];
return match[1].split(';').map(function (dir) {
return dir.trim();
}).filter(Boolean);
}
/** The read-only manifest whose serviceFiles make the updater launchable. */
function findUpdaterManifest() {
var found = null;
lsHubdDirs('ManifestsDirectories').forEach(function (dir) {
if (found || !isDirectory(dir)) return;
var entries;
try {
entries = fs.readdirSync(dir);
} catch (err) {
return;
}
entries.forEach(function (entry) {
if (found || !/\.json$/.test(entry)) return;
var file = path.join(dir, entry);
var manifest = readJson(file, null);
if (!manifest || !manifest.id || !Array.isArray(manifest.serviceFiles)) return;
var launchesUpdater = manifest.serviceFiles.some(function (svc) {
return path.basename(svc) === UPDATER_SERVICE + '.service';
});
if (launchesUpdater) found = { file: file, manifest: manifest };
});
});
return found;
}
/** Where our replacement goes - the first writable manifest dir ls-hubd scans. */
function overridePath(base) {
var dirs = lsHubdDirs('ManifestsVolatileDirectories');
var dir = dirs.filter(isDirectory)[0] || dirs[0];
return dir ? path.join(dir, base.manifest.id + '.json') : null;
}
/** A version that outranks the stock manifest, so ls-hubd prefers ours. */
function outrankVersion(version) {
var major = parseInt(String(version || '').split('.')[0], 10);
return Math.max((major >= 0 ? major : 0) + 1, 99) + '.0.0';
}
/* Same id, higher version, same roles and permissions - only the serviceFiles
* list is emptied, which is what takes away ls-hubd's ability to launch it. */
function buildOverride(base) {
var override = { id: base.manifest.id, version: outrankVersion(base.manifest.version) };
['roleFiles', 'roleFilesPub', 'roleFilesPrv', 'apiPermissionFiles', 'clientPermissionFiles']
.forEach(function (key) {
if (base.manifest[key]) override[key] = base.manifest[key];
});
override.serviceFiles = [];
return override;
}
/* An empty serviceFiles list is not something a real manifest ever has, so it
* doubles as the signature that a file at that path was written by us. */
function isOverrideOurs(target, base) {
var manifest = readJson(target, null);
return !!manifest && manifest.id === base.manifest.id &&
Array.isArray(manifest.serviceFiles) && manifest.serviceFiles.length === 0;
}
/** Exec= out of the updater's D-Bus service file. */
function updaterBinary(base) {
var files = base.manifest.serviceFiles || [];
for (var i = 0; i < files.length; i += 1) {
var match = readFile(files[i], '').match(/^[ \t]*Exec[ \t]*=[ \t]*(\S+)/m);
if (match) return match[1];
}
return UPDATER_BINARY_FALLBACK;
}
function updaterPids(binary) {
var pids = [];
try {
fs.readdirSync('/proc').forEach(function (entry) {
if (!/^[0-9]+$/.test(entry)) return;
/* cmdline is NUL-separated; argv[0] is what ls-hubd launched */
if (readFile('/proc/' + entry + '/cmdline', '').split('\0')[0] === binary) {
pids.push(parseInt(entry, 10));
}
});
} catch (err) {
/* /proc unreadable - nothing we can do */
}
return pids;
}
function stopUpdater(binary) {
var stopped = 0;
updaterPids(binary).forEach(function (pid) {
try {
process.kill(pid, 'SIGTERM');
stopped += 1;
} catch (err) {
/* already gone */
}
});
return stopped;
}
function blockUpdater(log) {
var base = findUpdaterManifest();
if (!base) {
log.add('! no ' + UPDATER_SERVICE + ' manifest on this TV - leaving the updater alone');
return false;
}
var target = overridePath(base);
if (!target) {
log.add('! ls-hubd has no writable manifest directory on this TV');
return false;
}
if (exists(target) && !isOverrideOurs(target, base)) {
log.add('! ' + target + ' exists and is not ours - leaving it alone');
return false;
}
try {
mkdirp(path.dirname(target));
writeJson(target, buildOverride(base));
} catch (err) {
log.add('! could not write ' + target + ': ' + err.message);
return false;
}
var res = sh('ls-control scan-services');
if (!res.ok) log.add('! ls-control scan-services failed: ' + res.output);
log.add('updater disabled - ' + UPDATER_SERVICE + ' can no longer be launched');
var binary = updaterBinary(base);
var stopped = stopUpdater(binary);
if (stopped) log.add('stopped ' + stopped + ' running ' + binary + ' process(es)');
return true;
}
function unblockUpdater(log) {
var base = findUpdaterManifest();
var target = base && overridePath(base);
if (!target || !exists(target)) {
log.add('updater was not disabled');
return;
}
if (!isOverrideOurs(target, base)) {
log.add('! ' + target + ' is not ours - leaving it alone');
return;
}
try {
fs.unlinkSync(target);
} catch (err) {
log.add('! could not remove ' + target + ': ' + err.message);
return;
}
sh('ls-control scan-services');
log.add('updater re-enabled - removed ' + target);
}
function updaterStatus() {
var base = findUpdaterManifest();
if (!base) return { found: false, blocked: false, running: false };
var target = overridePath(base);
var binary = updaterBinary(base);
return {
found: true,
service: UPDATER_SERVICE,
binary: binary,
manifest: base.file,
overridePath: target,
blocked: !!(target && exists(target) && isOverrideOurs(target, base)),
running: updaterPids(binary).length > 0
};
}
/* ------------------------------------------------------------------ hosts */ /* ------------------------------------------------------------------ hosts */
function readDomains() { function readDomains() {
@@ -692,6 +896,37 @@ function restoreSettings(log, callback) {
/* -------------------------------------------------------------- boot hook */ /* -------------------------------------------------------------- boot hook */
/* The updater override lives on a persistent partition, so unlike the other
* layers it normally survives a reboot on its own. The hook only puts it back
* if something removed it - a firmware update, or an app install that rewrote
* the volatile manifest directory. */
function updaterHookLines(config) {
var base = config.blockUpdater ? findUpdaterManifest() : null;
var target = base && overridePath(base);
if (!target) return [];
return [
'UPDATER_MANIFEST=' + target,
'UPDATER_BIN=' + updaterBinary(base),
'',
'if [ ! -f "$UPDATER_MANIFEST" ]; then',
' mkdir -p "$(dirname "$UPDATER_MANIFEST")"',
' cat > "$UPDATER_MANIFEST" <<\'LGUB_MANIFEST\'',
JSON.stringify(buildOverride(base), null, 2),
'LGUB_MANIFEST',
' ls-control scan-services >/dev/null 2>&1',
' echo "restored updater override $UPDATER_MANIFEST"',
'fi',
'# it cannot be launched any more, but kill one that slipped through',
'for proc in /proc/[0-9]*; do',
' [ -r "$proc/cmdline" ] || continue',
' case "$(tr \'\\0\' \' \' < "$proc/cmdline")" in',
' "$UPDATER_BIN "*) kill "${proc#/proc/}" 2>/dev/null && echo "stopped $UPDATER_BIN" ;;',
' esac',
'done',
''
];
}
function bootHookScript(config, domains) { function bootHookScript(config, domains) {
return [ return [
'#!/bin/sh', '#!/bin/sh',
@@ -712,9 +947,10 @@ function bootHookScript(config, domains) {
'if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt 65536 ]; then rm -f "$LOG"; fi', 'if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt 65536 ]; then rm -f "$LOG"; fi',
'exec >>"$LOG" 2>&1', 'exec >>"$LOG" 2>&1',
'echo "--- $(date) LG Update Blocker ---"', 'echo "--- $(date) LG Update Blocker ---"',
'', ''
'# First, because by now the popup has been on screen for ~20 seconds: the', ].concat(updaterHookLines(config)).concat([
'# updater runs its version check from webos-mbd.target, long before the', '# Fallback for when the updater layer is off: by now the popup has been on',
'# screen for ~20 seconds, because the version check runs long before the',
'# Homebrew Channel gets round to running these hooks.', '# Homebrew Channel gets round to running these hooks.',
'if [ "$DISMISS_POPUP" = 1 ]; then', 'if [ "$DISMISS_POPUP" = 1 ]; then',
' id=$(sed -n \'s/.*_gAlertWindowId : \\(' + ALERT_ID_PATTERN + '\\).*/\\1/p\' \\', ' id=$(sed -n \'s/.*_gAlertWindowId : \\(' + ALERT_ID_PATTERN + '\\).*/\\1/p\' \\',
@@ -787,7 +1023,7 @@ function bootHookScript(config, domains) {
'', '',
'echo "done"', 'echo "done"',
'' ''
].join('\n'); ]).join('\n');
} }
/* Long-lived companion to the boot hook: subscribes to the notification /* Long-lived companion to the boot hook: subscribes to the notification
@@ -888,6 +1124,7 @@ function baseStatus() {
uid: typeof process.getuid === 'function' ? process.getuid() : -1, uid: typeof process.getuid === 'function' ? process.getuid() : -1,
root: isRoot(), root: isRoot(),
config: readConfig(), config: readConfig(),
updater: updaterStatus(),
hosts: { hosts: {
path: HOSTS_PATH, path: HOSTS_PATH,
writable: hostsWritable(), writable: hostsWritable(),
@@ -933,6 +1170,13 @@ service.register('apply', function (message) {
mkdirp(STATE_DIR); mkdirp(STATE_DIR);
writeJson(CONFIG_PATH, config); writeJson(CONFIG_PATH, config);
/* first: with the updater gone there is no version check to lose a race to */
if (config.blockUpdater) {
blockUpdater(log);
} else {
unblockUpdater(log);
}
if (config.blockHosts) { if (config.blockHosts) {
applyHosts(log); applyHosts(log);
} else { } else {
@@ -992,6 +1236,7 @@ service.register('revert', function (message) {
}); });
try { try {
unblockUpdater(log);
removeHosts(log); removeHosts(log);
unlockCache(log); unlockCache(log);
if (stopWatcher()) log.add('alert watcher stopped'); if (stopWatcher()) log.add('alert watcher stopped');