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:
co-authored by
Claude Opus 5
parent
ef8b6b96c4
commit
6fa8ad62f6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user