feat: stop the updater being launched at all
Build / build (push) Successful in 48s

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 80e2d7e5fa
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
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
`webos-mbd.target`) and runs its version check against
The updater runs its version check against
`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
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
```
Losing that race is not fixable from userspace. Every directory systemd reads
units from is a read-only overlay except `/run/systemd/system`, which is tmpfs
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.
That race cannot be won — nothing unprivileged runs earlier than those hooks —
so the app removes it instead.
So this app **closes the popup** instead of trying to prevent it, and blocks
the hostnames to stop the download and every later check in the session. Two
things also turned out not to be true, and the app no longer pretends
otherwise: the popup is not caused by a staged firmware image (the staging
directory is empty at boot; the size the daemon reports is in-memory only), and
it is not caused by an update *service* that can be stopped — `/etc/init` is
dead upstart leftovers on a systemd TV, and `initctl stop update` silently
"succeeds" while doing nothing.
`/usr/sbin/update` is not a normal daemon you can stop. It is launched on
demand by **ls-hubd**, which finds it through a *manifest*: a small JSON file
listing the D-Bus service file that carries `Exec=/usr/sbin/update`. LG's own
manifest is on a read-only partition, but `/etc/luna-service2/ls-hubd.conf`
also lists `ManifestsVolatileDirectories` under `/var` — writable, persistent,
and the mechanism webOS itself uses to ship manifest updates. A manifest there
with the **same id and a higher version replaces** the read-only one.
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
| 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 |
| **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 |
| **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 |
@@ -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
settings from the backup it made.
Be honest about what this buys you: 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**. It is gone before you can act on it, and you never have to
dismiss it. 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. Measured on a CX: alert raised at `01:26:22`, closed at `01:26:37`.
With *Stop the updater running* on, the popup does not appear at all — there is
nothing to close, because the version check never happens. That layer lives on
a persistent partition, so it also survives a reboot without help; the boot hook
only puts it back if something (a firmware update, an app install that rewrote
the volatile manifest directory) removed it.
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
@@ -119,6 +155,7 @@ Other buttons: **Purge staged update** (wipe a downloaded image right now),
| 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 |
| `/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` |
@@ -133,15 +170,18 @@ replaced.
* **Badge says "no root"** — open the Homebrew Channel, verify *Root status* is
`ok`, then relaunch this app. If the Homebrew Channel itself is unelevated,
fix that first (its Settings screen has a button for it).
* **Popup still appears after a reboot** — run *Diagnostics* and look at the
boot-hook log at the bottom. It records, per boot, whether the hosts entries
were added, what was purged, and the id of the popup it closed. If there is
no `dismissed update popup …` line, check `updateLog` in the same dump for
`_gAlertWindowId` — if that is missing too, your TV raises the alert by some
other route and the diagnostics dump is what to open an issue with.
* **The popup appears for ~15 seconds, then disappears on its own** — expected, see
[Why blocking the update servers is not enough](#why-blocking-the-update-servers-is-not-enough).
Nothing running after the Homebrew Channel can beat the updater to it.
* **Popup still appears after a reboot** — run *Diagnostics* and check
`updater` in the status block. `blocked` should be `true` and `running`
`false`; if `found` is `false`, ls-hubd on your firmware locates the updater
some other way and the diagnostics dump is what to open an issue with. If it
says `blocked: true` and the popup still shows up, `updateLog` in the same
dump tells you whether the updater ran at all — on a working setup
`/tmp/var/log/update.log` does not exist.
* **The popup appears for ~15 seconds, then disappears on its own** — that is
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
`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.