Files
lg_update_blocker/README.md
T
Rene KievitsandClaude Opus 5 ef8b6b96c4
Build / build (push) Successful in 46s
fix: close the update popup instead of racing to prevent it
Blocking snu.lge.com in /etc/hosts never stopped the boot popup on a CX,
and the reason only showed up on the TV itself: /usr/sbin/update is a
systemd unit (webos-mbd.target) that runs its version check against
https://snu.lge.com/CheckSWAutoUpdate.laf 15-20 seconds before the
Homebrew Channel gets as far as running its init.d hooks. The check
therefore succeeds on every boot and the alert is already on screen
before any hosts entry exists. Homebrew Channel's own "block system
updates" toggle loses the same race.

Nothing running that late can win it: every systemd unit path is a
read-only overlay except tmpfs /run, and no persistent setting gates the
check - automaticUpdate, support/softwareUpdateEnable,
hotelMode/swUpdateEnable and .UpdateIsInprogress were each measured by
restarting the daemon and counting its requests.

So dismiss the popup instead. The boot hook recovers the alert id from
the updater's own log (_gAlertWindowId), which is the only way to reach
an alert that opened before we could subscribe - com.webos.notification
never reports it to a late subscriber and closeAllAlerts rejects every
source id it accepts. A companion alert-watch.sh then stays subscribed
for the rest of the session.

Also drop two things that were never true. There is no staged firmware
image driving the popup (the staging dir is empty at boot; the size the
daemon reports is in-memory only), and there is no update service to
stop - /etc/init is dead upstart leftovers on a systemd TV, so the old
stopServices layer printed "stopped update" while doing nothing.

  - rename the hook to 00-lgupdateblocker so run-parts runs it first,
    removing the legacy file on apply
  - add support/ and hotelMode/ to the scanned settings categories
  - stop matching "ota" inside screenRotation, which would have switched
    screen rotation off
  - kill the watcher by process group, and make its TERM trap exit - a
    trap that only returns resumes the script, which then re-subscribes

Verified on an LG OLED55CX8LB (webOS 5, 04.60.65): after a reboot the
boot log records "dismissed update popup
com.webos.service.update-1788650782451", matching the id the updater
logged that boot. Raised 01:26:22, closed 01:26:37 - so it is visible
for ~15s and then goes away on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:39:45 +02:00

9.5 KiB
Raw 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.

/usr/sbin/update is started by systemd (update.service, pulled in by webos-mbd.target) and 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

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.

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.

Protection layers

Layer What it does Default
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
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.

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.

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
/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 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. Nothing running after the Homebrew Channel can beat the updater to it.
  • 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