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>
235 lines
12 KiB
Markdown
235 lines
12 KiB
Markdown
<p align="center">
|
||
<img src="assets/icon400.png" width="120" alt="LG Update Blocker">
|
||
</p>
|
||
|
||
# LG Update Blocker
|
||
|
||
A webOS homebrew app that gets rid of the *"a new software version is
|
||
available"* popup LG shows on every boot — without touching the firmware
|
||
itself, and fully reversible.
|
||
|
||
Built for a rooted **LG CX (webOS 5)**, but nothing in it is model specific: it
|
||
only ever probes for things and acts on what it actually finds on your TV.
|
||
|
||

|
||
|
||
## Why blocking the update servers is not enough
|
||
|
||
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.
|
||
|
||
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
|
||
the popup is already on screen by the time any hosts entry exists:
|
||
|
||
```text
|
||
00:20:52 [tNsuMainTask] pRequestUpdateUrl - https://snu.lge.com/CheckSWAutoUpdate.laf
|
||
00:20:52 [updateTimer] NSU_CreateUpdateAlert - "…Version: 04.64.00…"
|
||
00:21:11 lg-update-blocker: hosts entries added ← 19 seconds too late
|
||
```
|
||
|
||
That race cannot be won — nothing unprivileged runs earlier than those hooks —
|
||
so the app removes it instead.
|
||
|
||
`/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** | 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 |
|
||
|
||
Every layer is undone by **Remove protection**, which also restores the
|
||
settings from the backup it made.
|
||
|
||
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
|
||
|
||
* A rooted webOS TV with the [Homebrew Channel](https://github.com/webosbrew/webos-homebrew-channel)
|
||
installed and its root status showing **ok**.
|
||
* The app's own service needs root. On first launch it asks the Homebrew
|
||
Channel to elevate it (`elevateService`, falling back to running
|
||
`elevate-service` through `exec`), then restarts itself. The header badge
|
||
turns green when it worked.
|
||
|
||
## Install
|
||
|
||
### Through the Homebrew Channel (repository)
|
||
|
||
Add the repository once — *Homebrew Channel → Settings → Add repository*:
|
||
|
||
```text
|
||
https://git.crylia.de/Crylia/lg_update_blocker/releases/download/latest/apps.json
|
||
```
|
||
|
||
The app then shows up in the Homebrew Channel list, and every new release that
|
||
is tagged is picked up automatically — Gitea resolves `.../download/latest/...`
|
||
to the newest release.
|
||
|
||
### Manually
|
||
|
||
Download the `.ipk` from the release and:
|
||
|
||
```sh
|
||
ares-install com.rkievits.lgupdateblocker_<version>_all.ipk
|
||
ares-launch com.rkievits.lgupdateblocker
|
||
```
|
||
|
||
## Using it
|
||
|
||
Navigate with ▲▼, select with OK, leave with BACK.
|
||
|
||
1. Check the layers you want (the defaults are a good start).
|
||
2. **Apply protection** — the log pane shows exactly what was changed.
|
||
3. Reboot and confirm the popup is gone.
|
||
|
||
Other buttons: **Purge staged update** (wipe a downloaded image right now),
|
||
**Remove protection** (undo everything), **Refresh status**, and
|
||
**Diagnostics** (dumps OS info, mounts, discovered settings, the tail of
|
||
`/etc/hosts` and the boot-hook log — handy for a bug report).
|
||
|
||
## What it touches
|
||
|
||
| 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` |
|
||
| `/tmp/var/log/update.log` | Read only — the updater logs the id of the alert it raised, which is the only way to close a popup that opened before we were subscribed |
|
||
| `/mnt/lg/cmn_data/swupdate` | Staged firmware, emptied (and optionally locked) |
|
||
|
||
Nothing is written to system partitions, and no LG binaries are patched or
|
||
replaced.
|
||
|
||
## Troubleshooting
|
||
|
||
* **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 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.
|
||
* **Forced *app* updates** (the *"You must update this app to launch it"*
|
||
dialog) are a different mechanism — that is what
|
||
[LG App Update Blocker](https://github.com/dr0dr1dr2dr3/lgappupdateblocker)
|
||
is for. This app deliberately leaves the `lgtvsdp.com` hosts alone, since
|
||
blocking those also breaks the store and several other services.
|
||
|
||
## Building
|
||
|
||
No bundler, no runtime dependencies — `tools/build.js` copies `app/` and
|
||
`service/` into `build/` and substitutes the app id and version.
|
||
|
||
```sh
|
||
npm install # only the ares CLI
|
||
npm run dist # build + package + manifest, output in dist/
|
||
npm run deploy # ares-install the resulting ipk
|
||
```
|
||
|
||
## Releasing
|
||
|
||
[.github/workflows/release.yml](.github/workflows/release.yml) builds on every
|
||
push (Gitea Actions reads `.github/workflows/` too). Tag a commit `v<version>`
|
||
(matching `version` in `package.json`) and the workflow additionally publishes a
|
||
release containing:
|
||
|
||
* `com.rkievits.lgupdateblocker_<version>_all.ipk`
|
||
* `com.rkievits.lgupdateblocker.manifest.json` — Homebrew Channel manifest,
|
||
with the absolute ipk URL, its sha256 and size
|
||
* `apps.json` — the one-package repository index used by the repository URL above
|
||
* `description.html` — the long description shown in the Homebrew Channel
|
||
|
||
Uploading is done by [tools/release-gitea.js](tools/release-gitea.js) against
|
||
the Gitea API (`softprops/action-gh-release` only speaks to github.com). It is
|
||
idempotent: re-running a tag reuses the release and replaces the attachments.
|
||
|
||
The URLs inside the manifest come from `PUBLIC_SERVER_URL` in the workflow, not
|
||
from `GITHUB_SERVER_URL` — the runner is handed the instance's internal LAN
|
||
address, which the TV cannot reach. Change that one variable if the instance
|
||
moves. Generating for github.com instead works too: `--server` (or
|
||
`PUBLIC_SERVER_URL`) switches the "latest release asset" URL to GitHub's shape.
|
||
|
||
The app id lives in exactly one place: the `name` field of `package.json`.
|
||
Change it there and everything (appinfo, service name, Luna URIs, manifest)
|
||
follows.
|
||
|
||
## License
|
||
|
||
MIT
|