Files
Rene KievitsandClaude Opus 5 80e2d7e5fa
Build / build (push) Successful in 48s
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>
2026-09-06 02:18:01 +02:00

12 KiB
Raw Permalink Blame History

LG Update Blocker

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.

screenshot

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:

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 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:

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:

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.
  • 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 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.

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 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 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