3 Commits
Author SHA1 Message Date
Rene KievitsandClaude Opus 5 80e2d7e5fa 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>
2026-09-06 02:18:01 +02:00
Rene KievitsandClaude Opus 5 ef8b6b96c4 fix: close the update popup instead of racing to prevent it
Build / build (push) Successful in 46s
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
Rene KievitsandClaude Opus 5 ae8229588e tools: add read-only on-TV diagnostic dump
Run with: ssh root@<tv> 'sh -s' < tools/tv-diag.sh > tv-diag.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:58:33 +02:00
9 changed files with 837 additions and 110 deletions
+1
View File
@@ -3,3 +3,4 @@ build/
dist/ dist/
*.ipk *.ipk
.DS_Store .DS_Store
tv-diag*.txt
+96 -18
View File
@@ -13,32 +13,99 @@ only ever probes for things and acts on what it actually finds on your TV.
![screenshot](docs/screenshot.png) ![screenshot](docs/screenshot.png)
## Why the popup keeps coming back ## Why blocking the update servers is not enough
The Homebrew Channel's own *Block system updates* toggle only writes four Blocking `snu.lge.com` in `/etc/hosts` is the obvious fix, and it is what the
hostnames into `/etc/hosts` at boot. That is not enough in practice: 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.
* it misses several update hostnames (`nsu`, `*-ssl`, the CDN aliases), The updater runs its version check against
* it does nothing about a firmware image the TV **already downloaded** — a `https://snu.lge.com/CheckSWAutoUpdate.laf` roughly **20 seconds before** the
staged update keeps prompting even when the update servers are unreachable, Homebrew Channel gets as far as running its `init.d` hooks. So on every boot
* it leaves the auto-update settings and the update services alone. 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:
This app covers all of that, and re-applies everything on each boot (webOS ```text
resets `/`, `/tmp` and the mount namespace on every start). 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 ## 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 |
| **Delete staged firmware** | Wipes the update image staged in `/mnt/lg/cmn_data/swupdate` | 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 | | **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 | | **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 |
| **Stop update services at boot** | Stops the update-related upstart jobs discovered in `/etc/init` (advanced) | off |
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.
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 ## Requirements
* A rooted webOS TV with the [Homebrew Channel](https://github.com/webosbrew/webos-homebrew-channel) * A rooted webOS TV with the [Homebrew Channel](https://github.com/webosbrew/webos-homebrew-channel)
@@ -67,7 +134,7 @@ to the newest release.
Download the `.ipk` from the release and: Download the `.ipk` from the release and:
```sh ```sh
ares-install com.rkievits.lgupdateblocker_1.0.0_all.ipk ares-install com.rkievits.lgupdateblocker_<version>_all.ipk
ares-launch com.rkievits.lgupdateblocker ares-launch com.rkievits.lgupdateblocker
``` ```
@@ -88,9 +155,11 @@ 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/lgupdateblocker` | Boot hook, run by the Homebrew Channel startup script; re-applies the enabled layers | | `/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` | | `/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) | | `/mnt/lg/cmn_data/swupdate` | Staged firmware, emptied (and optionally locked) |
Nothing is written to system partitions, and no LG binaries are patched or Nothing is written to system partitions, and no LG binaries are patched or
@@ -101,9 +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 which jobs were stopped. `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 * **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.
+23 -8
View File
@@ -13,11 +13,21 @@
'/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',
desc: 'Points snu/su/nsu.lge.com at 127.0.0.1 in /etc/hosts, re-applied on every boot' desc: 'Points snu/su/nsu.lge.com at 127.0.0.1 in /etc/hosts, re-applied on every boot'
}, },
{
key: 'dismissPopup',
title: 'Close the update popup',
desc: 'Fallback: closes a popup that appears anyway, e.g. with the updater left running'
},
{ {
key: 'purgeCache', key: 'purgeCache',
title: 'Delete staged firmware', title: 'Delete staged firmware',
@@ -32,11 +42,6 @@
key: 'disableSettings', key: 'disableSettings',
title: 'Turn off auto-update settings', title: 'Turn off auto-update settings',
desc: 'Switches off every update-related key com.webos.settingsservice exposes' desc: 'Switches off every update-related key com.webos.settingsservice exposes'
},
{
key: 'stopServices',
title: 'Stop update services at boot',
desc: 'Advanced: stops the update upstart jobs found on this TV after every boot'
} }
]; ];
@@ -49,11 +54,12 @@
]; ];
var config = { var config = {
blockUpdater: true,
blockHosts: true, blockHosts: true,
dismissPopup: true,
purgeCache: true, purgeCache: true,
lockCache: false, lockCache: false,
disableSettings: true, disableSettings: true
stopServices: false
}; };
var focusables = []; var focusables = [];
@@ -224,12 +230,21 @@
}); });
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');
row('Staged firmware', stagedDirs ? bytes(staged) : '-'); row('Staged firmware', stagedDirs ? bytes(staged) : '-');
row('Staging locked', stagedDirs ? locked + ' / ' + stagedDirs : '-'); row('Staging locked', stagedDirs ? locked + ' / ' + stagedDirs : '-');
row('Update jobs found', (status.jobs || []).length ? status.jobs.join(', ') : 'none'); var popup = status.popup || {};
row('Update popup this boot', popup.lastAlertId ? 'raised, then closed' : 'none raised');
row('Popup watcher', popup.watcherPid ? 'running (pid ' + popup.watcherPid + ')' : 'not running');
row('Boot hook', status.bootHook.installed ? 'installed' : 'not installed'); row('Boot hook', status.bootHook.installed ? 'installed' : 'not installed');
var settings = status.settings || []; var settings = status.settings || [];
+24 -8
View File
@@ -1,23 +1,39 @@
<p><b>LG Update Blocker</b> stops the "a new software version is available" popup <p><b>LG Update Blocker</b> stops the "a new software version is available" popup
that webOS shows on every boot, without touching the firmware itself.</p> that webOS shows on every boot, without touching the firmware itself.</p>
<p>It applies up to five reversible layers and re-applies them on every boot <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
hooks, so on a CX the check succeeds every time and the popup is already up.
Nothing running that late can win that race, so this app removes it instead -
it takes the updater off the bus entirely, and the check never happens.</p>
<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.</li> and CDN aliases at 127.0.0.1 in /etc/hosts, which stops the download and
<li><b>Delete staged firmware</b> - wipes /mnt/lg/cmn_data/swupdate. An update every later check in the session.</li>
that was already downloaded keeps prompting even when the servers are <li><b>Close the update popup</b> - fallback for a popup that appears anyway:
unreachable, which is why blocking alone often is not enough.</li> closes the alert raised during boot, then stays subscribed to the
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
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
over it so nothing can be staged again.</li> over it so nothing can be staged again.</li>
<li><b>Turn off auto-update settings</b> - switches off every update related <li><b>Turn off auto-update settings</b> - switches off every update related
key that com.webos.settingsservice exposes on your firmware, remembering key that com.webos.settingsservice exposes on your firmware, remembering
the original values.</li> the original values.</li>
<li><b>Stop update services at boot</b> - optional, stops the update upstart
jobs found on the TV.</li>
</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>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "com.rkievits.lgupdateblocker", "name": "com.rkievits.lgupdateblocker",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "com.rkievits.lgupdateblocker", "name": "com.rkievits.lgupdateblocker",
"version": "1.0.0", "version": "1.1.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@webos-tools/cli": "^3.2.1" "@webos-tools/cli": "^3.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "com.rkievits.lgupdateblocker", "name": "com.rkievits.lgupdateblocker",
"version": "1.0.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,
+487 -73
View File
@@ -3,15 +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. purge - delete the firmware image the TV already staged * 2. hosts - point LG's firmware update servers at 127.0.0.1
* 3. lock - bind-mount an empty read-only dir over the staging dir * 3. dismiss - close the "software update available" alert at boot
* 4. settings - switch off the update-related com.webos.settingsservice keys * 4. purge - delete the firmware image the TV already staged
* 5. services - stop the update related upstart jobs (advanced, opt-in) * 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 runs its version
* check against snu.lge.com roughly 20 seconds before the Homebrew Channel
* gets as far as running the hooks in init.d. On a CX that check therefore
* *succeeds* on every boot and the popup is already on screen by the time
* anything of ours runs - which is also why the Homebrew Channel's own "block
* system updates" toggle does not stop it, and why closing the popup after the
* fact leaves it visible for those 20 seconds.
*
* The updater layer removes the race instead of trying to win it. The updater
* is not a normal daemon: it is launched on demand by ls-hubd from a D-Bus
* service file, which ls-hubd finds through a manifest. ls-hubd.conf lists
* ManifestsVolatileDirectories under /var - writable, persistent, and how
* 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.
@@ -37,7 +66,13 @@ var SETTINGS_BACKUP_PATH = STATE_DIR + '/settings-backup.json';
var EMPTY_DIR = STATE_DIR + '/empty'; var EMPTY_DIR = STATE_DIR + '/empty';
var BOOT_LOG_PATH = STATE_DIR + '/boot.log'; var BOOT_LOG_PATH = STATE_DIR + '/boot.log';
var BOOT_HOOK_DIR = '/var/lib/webosbrew/init.d'; var BOOT_HOOK_DIR = '/var/lib/webosbrew/init.d';
var BOOT_HOOK_PATH = BOOT_HOOK_DIR + '/' + SLUG; /* run-parts runs the hooks in lexical order and the popup is already on screen
* by then, so sort ahead of the other homebrew hooks. Older versions installed
* the hook under the bare slug; installBootHook removes that leftover. */
var BOOT_HOOK_PATH = BOOT_HOOK_DIR + '/00-' + SLUG;
var LEGACY_BOOT_HOOK_PATH = BOOT_HOOK_DIR + '/' + SLUG;
var WATCHER_PATH = STATE_DIR + '/alert-watch.sh';
var WATCHER_PID_PATH = '/tmp/' + SLUG + '-watch.pid';
var HOSTS_PATH = '/etc/hosts'; var HOSTS_PATH = '/etc/hosts';
var HOSTS_TMP = '/tmp/' + SLUG + '-hosts'; var HOSTS_TMP = '/tmp/' + SLUG + '-hosts';
@@ -56,18 +91,41 @@ var CACHE_DIRS = [
]; ];
var CACHE_GUARD = '/mnt/lg/'; var CACHE_GUARD = '/mnt/lg/';
var UPSTART_DIR = '/etc/init'; /* /usr/sbin/update logs its own alert id here as
var JOB_PATTERN = /(swupdate|softwareupdate|firmware|fota|update|upgrade|nsu)/i; * _NSU_CreateAlertCallback - _gAlertWindowId : com.webos.service.update-<ms>
* which is the only way to learn the id of a popup that opened before we did:
* com.webos.notification only pushes alerts to clients already subscribed, and
* closeAllAlerts rejects every source id we can pass it. */
var UPDATE_DAEMON_LOG = '/tmp/var/log/update.log';
var ALERT_ID_PATTERN = 'com\\.webos\\.service\\.update-[0-9]*';
var SETTINGS_CATEGORIES = ['option', 'general', 'network', 'commercial']; /* ls-hubd launches the updater on demand; its config says which manifest
var SETTINGS_KEY_PATTERN = /(update|upgrade|firmware|ota)/i; * 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_KEY_PATTERN = /(update|upgrade|firmware)/i;
/* "ota" and "nsu" are substrings of perfectly innocent keys - screenRotation,
* consumerMode - and switching one of those off would be a nasty surprise, so
* only match them at a word or camelCase boundary. */
var SETTINGS_ABBREV_PATTERN = /(^|[^A-Za-z])(ota|nsu)|Ota|OTA|Nsu|NSU/;
function isUpdateKey(key) {
return SETTINGS_KEY_PATTERN.test(key) || SETTINGS_ABBREV_PATTERN.test(key);
}
var DEFAULT_CONFIG = { var DEFAULT_CONFIG = {
blockUpdater: true,
blockHosts: true, blockHosts: true,
dismissPopup: true,
purgeCache: true, purgeCache: true,
lockCache: false, lockCache: false,
disableSettings: true, disableSettings: true
stopServices: false
}; };
var CONFIG_KEYS = Object.keys(DEFAULT_CONFIG); var CONFIG_KEYS = Object.keys(DEFAULT_CONFIG);
@@ -169,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() {
@@ -411,34 +648,83 @@ function unlockCache(log) {
}); });
} }
/* ----------------------------------------------------------- upstart jobs */ /* ------------------------------------------------------------ update popup */
function discoverJobs() { /* Id of the last update alert the daemon raised this boot, recovered from its
var entries; * own log. /tmp is wiped at boot, so at boot time this is the popup currently
try { * on screen; later in a session it may already have been closed. Closing an
entries = fs.readdirSync(UPSTART_DIR); * alert that is gone is a no-op, so it is safe either way. */
} catch (err) { function lastAlertId() {
return []; var log = readFile(UPDATE_DAEMON_LOG, '');
} var matches = log.match(/_gAlertWindowId\s*:\s*(com\.webos\.service\.update-[0-9]+)/g);
return entries if (!matches || !matches.length) return null;
.filter(function (entry) { return matches[matches.length - 1].replace(/^.*:\s*/, '');
return /\.conf$/.test(entry) && JOB_PATTERN.test(entry);
})
.map(function (entry) {
return entry.replace(/\.conf$/, '');
})
.sort();
} }
function stopJobs(jobs, log) { function closeAlert(alertId, callback) {
if (!jobs.length) { callLuna('luna://com.webos.notification/closeAlert', { alertId: alertId }, callback);
log.add('no update-related upstart jobs found'); }
function watcherPid() {
var pid = parseInt(readFile(WATCHER_PID_PATH, ''), 10);
if (!pid || !exists('/proc/' + pid)) return 0;
/* the pid file survives a crash; make sure it is still our watcher */
var cmdline = readFile('/proc/' + pid + '/cmdline', '');
return cmdline.indexOf('alert-watch') >= 0 ? pid : 0;
}
function stopWatcher() {
var pid = watcherPid();
if (!pid) return false;
/* the watcher is a pipeline in its own session (setsid / detached spawn), so
* kill the whole group - killing the script alone orphans its luna-send */
sh('kill -TERM -' + pid + ' 2>/dev/null || kill ' + pid);
try {
fs.unlinkSync(WATCHER_PID_PATH);
} catch (err) {
/* already gone */
}
return true;
}
function startWatcher(log) {
if (watcherPid()) {
log.add('alert watcher already running');
return; return;
} }
jobs.forEach(function (job) { if (!exists(WATCHER_PATH)) {
var res = sh('initctl stop ' + job); log.add('! alert watcher missing at ' + WATCHER_PATH);
if (!res.ok) res = sh('stop ' + job); return;
log.add(res.ok ? 'stopped upstart job ' + job : '! could not stop ' + job + ': ' + res.output); }
try {
var child = childProcess.spawn('/bin/sh', [WATCHER_PATH], {
detached: true,
stdio: 'ignore'
});
child.unref();
log.add('alert watcher started');
} catch (err) {
log.add('! could not start alert watcher: ' + err.message);
}
}
/* Close a popup that is on screen right now, then keep watching for the next
* one. Used by apply(); at boot the hook does the same thing in shell. */
function dismissPopup(log, done) {
var alertId = lastAlertId();
if (!alertId) {
log.add('no update popup raised this boot');
startWatcher(log);
return done();
}
closeAlert(alertId, function (res) {
log.add(
res && res.returnValue
? 'closed update popup ' + alertId
: '! could not close ' + alertId + ': ' + ((res && res.errorText) || 'no response')
);
startWatcher(log);
done();
}); });
} }
@@ -495,7 +781,7 @@ function discoverSettings(callback) {
callLuna('luna://com.webos.settingsservice/getSystemSettings', { category: category }, function (payload) { callLuna('luna://com.webos.settingsservice/getSystemSettings', { category: category }, function (payload) {
var settings = (payload && payload.settings) || {}; var settings = (payload && payload.settings) || {};
Object.keys(settings).forEach(function (key) { Object.keys(settings).forEach(function (key) {
if (!SETTINGS_KEY_PATTERN.test(key)) return; if (!isUpdateKey(key)) return;
found.push({ found.push({
category: category, category: category,
key: key, key: key,
@@ -610,7 +896,38 @@ function restoreSettings(log, callback) {
/* -------------------------------------------------------------- boot hook */ /* -------------------------------------------------------------- boot hook */
function bootHookScript(config, domains, jobs) { /* 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) {
return [ return [
'#!/bin/sh', '#!/bin/sh',
'# LG Update Blocker boot hook - generated by ' + pkgInfo.name + ' v' + pkgInfo.version + '.', '# LG Update Blocker boot hook - generated by ' + pkgInfo.name + ' v' + pkgInfo.version + '.',
@@ -619,17 +936,39 @@ function bootHookScript(config, domains, jobs) {
'STATE=' + STATE_DIR, 'STATE=' + STATE_DIR,
'LOG=' + BOOT_LOG_PATH, 'LOG=' + BOOT_LOG_PATH,
'HOSTS_TMP=' + HOSTS_TMP, 'HOSTS_TMP=' + HOSTS_TMP,
'WATCHER=' + WATCHER_PATH,
'BLOCK_HOSTS=' + (config.blockHosts ? 1 : 0), 'BLOCK_HOSTS=' + (config.blockHosts ? 1 : 0),
'DISMISS_POPUP=' + (config.dismissPopup ? 1 : 0),
'PURGE_CACHE=' + (config.purgeCache ? 1 : 0), 'PURGE_CACHE=' + (config.purgeCache ? 1 : 0),
'LOCK_CACHE=' + (config.lockCache ? 1 : 0), 'LOCK_CACHE=' + (config.lockCache ? 1 : 0),
'STOP_SERVICES=' + (config.stopServices ? 1 : 0),
'CACHE_DIRS="' + CACHE_DIRS.join(' ') + '"', 'CACHE_DIRS="' + CACHE_DIRS.join(' ') + '"',
'JOBS="' + jobs.join(' ') + '"',
'', '',
'mkdir -p "$STATE"', 'mkdir -p "$STATE"',
'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 ---"',
''
].concat(updaterHookLines(config)).concat([
'# 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.',
'if [ "$DISMISS_POPUP" = 1 ]; then',
' id=$(sed -n \'s/.*_gAlertWindowId : \\(' + ALERT_ID_PATTERN + '\\).*/\\1/p\' \\',
' ' + UPDATE_DAEMON_LOG + ' 2>/dev/null | tail -1)',
' if [ -n "$id" ] && luna-send -t 1 -f luna://com.webos.notification/closeAlert \\',
' "{\\"alertId\\":\\"$id\\"}" >/dev/null 2>&1; then',
' echo "dismissed update popup $id"',
' fi',
' # and stay subscribed for the rest of the session in case it comes back',
' if [ -x "$WATCHER" ]; then',
' if command -v setsid >/dev/null 2>&1; then',
' setsid "$WATCHER" >/dev/null 2>&1 </dev/null &',
' else',
' "$WATCHER" >/dev/null 2>&1 </dev/null &',
' fi',
' echo "alert watcher started"',
' fi',
'fi',
'', '',
'if [ "$BLOCK_HOSTS" = 1 ] && [ -f "$STATE/hosts.txt" ]; then', 'if [ "$BLOCK_HOSTS" = 1 ] && [ -f "$STATE/hosts.txt" ]; then',
' if ! (: >> /etc/hosts) 2>/dev/null; then', ' if ! (: >> /etc/hosts) 2>/dev/null; then',
@@ -682,42 +1021,99 @@ function bootHookScript(config, domains, jobs) {
' done', ' done',
'fi', 'fi',
'', '',
'if [ "$STOP_SERVICES" = 1 ] && [ -n "$JOBS" ]; then',
' for job in $JOBS; do',
' if initctl stop "$job" >/dev/null 2>&1 || stop "$job" >/dev/null 2>&1; then',
' echo "stopped $job"',
' else',
' echo "could not stop $job (not running?)"',
' fi',
' done',
'fi',
'',
'echo "done"', 'echo "done"',
'' ''
]).join('\n');
}
/* Long-lived companion to the boot hook: subscribes to the notification
* manager and closes update alerts as they open. Kept as a separate file so
* the hook itself stays a short, ordinary run-parts script. */
function watcherScript() {
return [
'#!/bin/sh',
'# LG Update Blocker alert watcher - generated by ' + pkgInfo.name + ' v' + pkgInfo.version + '.',
'# Closes LG\'s "a new software version is available" alert as it opens.',
'',
'LOG=' + BOOT_LOG_PATH,
'PIDFILE=' + WATCHER_PID_PATH,
'',
'echo $$ > "$PIDFILE"',
'# the INT/TERM handler has to exit explicitly: a trap that just returns',
'# resumes the script, which would then re-subscribe and outlive the kill',
'trap \'rm -f "$PIDFILE"\' EXIT',
'trap \'rm -f "$PIDFILE"; exit 0\' INT TERM',
'',
'note() { echo "$(date) $*" >> "$LOG"; }',
'',
'close_alert() {',
' [ -n "$1" ] || return 1',
' luna-send -t 1 -f luna://com.webos.notification/closeAlert \\',
' "{\\"alertId\\":\\"$1\\"}" >/dev/null 2>&1',
'}',
'',
'# getAlertNotification only pushes to clients that were already subscribed,',
'# so it never reports the popup that is on screen right now - and',
'# closeAllAlerts rejects every source id it accepts arguments for. The',
'# subscription below therefore only covers alerts raised from now on; the',
'# boot hook handles the one already open, using the updater\'s own log.',
'while :; do',
' luna-send -i -f luna://com.webos.notification/getAlertNotification \\',
' \'{"subscribe":true}\' 2>/dev/null |',
' while read -r line; do',
' case "$line" in',
' *com.webos.service.update-*) ;;',
' *) continue ;;',
' esac',
' id=$(echo "$line" | sed -n \'s/.*\\(' + ALERT_ID_PATTERN + '\\).*/\\1/p\')',
' close_alert "$id" || continue',
' # one line per popup, not per notification the bus repeats',
' [ "$id" = "$last" ] && continue',
' last=$id',
' note "dismissed update popup $id"',
' done',
' # the bus dropped us; wait before re-subscribing rather than spinning',
' sleep 30',
'done',
''
].join('\n'); ].join('\n');
} }
function installBootHook(config, jobs, log) { function installBootHook(config, log) {
var domains = readDomains(); var domains = readDomains();
mkdirp(STATE_DIR); mkdirp(STATE_DIR);
mkdirp(BOOT_HOOK_DIR); mkdirp(BOOT_HOOK_DIR);
fs.writeFileSync(HOSTS_LIST_PATH, domains.join('\n') + '\n'); fs.writeFileSync(HOSTS_LIST_PATH, domains.join('\n') + '\n');
fs.writeFileSync(BOOT_HOOK_PATH, bootHookScript(config, domains, jobs)); fs.writeFileSync(WATCHER_PATH, watcherScript());
fs.chmodSync(WATCHER_PATH, parseInt('755', 8));
fs.writeFileSync(BOOT_HOOK_PATH, bootHookScript(config, domains));
fs.chmodSync(BOOT_HOOK_PATH, parseInt('755', 8)); fs.chmodSync(BOOT_HOOK_PATH, parseInt('755', 8));
log.add('boot hook installed at ' + BOOT_HOOK_PATH); log.add('boot hook installed at ' + BOOT_HOOK_PATH);
/* versions before 1.1 installed the hook under the bare slug, which
* run-parts would then run a second time */
if (exists(LEGACY_BOOT_HOOK_PATH)) {
try {
fs.unlinkSync(LEGACY_BOOT_HOOK_PATH);
log.add('removed old boot hook ' + LEGACY_BOOT_HOOK_PATH);
} catch (err) {
log.add('! could not remove old boot hook: ' + err.message);
}
}
} }
function removeBootHook(log) { function removeBootHook(log) {
if (!exists(BOOT_HOOK_PATH)) { var removed = [BOOT_HOOK_PATH, LEGACY_BOOT_HOOK_PATH].filter(function (target) {
log.add('no boot hook installed'); if (!exists(target)) return false;
return; try {
} fs.unlinkSync(target);
try { return true;
fs.unlinkSync(BOOT_HOOK_PATH); } catch (err) {
log.add('removed boot hook ' + BOOT_HOOK_PATH); log.add('! could not remove boot hook: ' + err.message);
} catch (err) { return false;
log.add('! could not remove boot hook: ' + err.message); }
} });
log.add(removed.length ? 'removed boot hook ' + removed.join(', ') : 'no boot hook installed');
} }
/* ---------------------------------------------------------------- methods */ /* ---------------------------------------------------------------- methods */
@@ -728,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(),
@@ -736,7 +1133,10 @@ function baseStatus() {
totalDomains: readDomains().length totalDomains: readDomains().length
}, },
cache: cacheStatus(), cache: cacheStatus(),
jobs: discoverJobs(), popup: {
lastAlertId: lastAlertId(),
watcherPid: watcherPid()
},
bootHook: { bootHook: {
path: BOOT_HOOK_PATH, path: BOOT_HOOK_PATH,
installed: exists(BOOT_HOOK_PATH) installed: exists(BOOT_HOOK_PATH)
@@ -770,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 {
@@ -784,13 +1191,8 @@ service.register('apply', function (message) {
unlockCache(log); unlockCache(log);
} }
var jobs = discoverJobs();
if (config.stopServices) {
stopJobs(jobs, log);
}
if (anyEnabled(config)) { if (anyEnabled(config)) {
installBootHook(config, config.stopServices ? jobs : [], log); installBootHook(config, log);
} else { } else {
removeBootHook(log); removeBootHook(log);
} }
@@ -805,10 +1207,19 @@ service.register('apply', function (message) {
message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() }); message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() });
} }
function afterSettings() {
if (config.dismissPopup) {
dismissPopup(log, finish);
} else {
if (stopWatcher()) log.add('alert watcher stopped');
finish();
}
}
if (config.disableSettings) { if (config.disableSettings) {
disableSettings(log, finish); disableSettings(log, afterSettings);
} else { } else {
restoreSettings(log, finish); restoreSettings(log, afterSettings);
} }
}); });
@@ -825,8 +1236,10 @@ service.register('revert', function (message) {
}); });
try { try {
unblockUpdater(log);
removeHosts(log); removeHosts(log);
unlockCache(log); unlockCache(log);
if (stopWatcher()) log.add('alert watcher stopped');
removeBootHook(log); removeBootHook(log);
mkdirp(STATE_DIR); mkdirp(STATE_DIR);
writeJson(CONFIG_PATH, config); writeJson(CONFIG_PATH, config);
@@ -835,7 +1248,7 @@ service.register('revert', function (message) {
} }
restoreSettings(log, function () { restoreSettings(log, function () {
log.add('everything reverted; stopped services come back after a reboot'); log.add('everything reverted - the update popup will be back on the next boot');
message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() }); message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() });
}); });
}); });
@@ -860,6 +1273,7 @@ service.register('diagnostics', function (message) {
osInfo: readJson('/var/run/nyx/os_info.json', null), osInfo: readJson('/var/run/nyx/os_info.json', null),
hostsFile: readFile(HOSTS_PATH, '(unreadable)').split('\n').slice(-40).join('\n'), hostsFile: readFile(HOSTS_PATH, '(unreadable)').split('\n').slice(-40).join('\n'),
bootLog: readFile(BOOT_LOG_PATH, '(no boot log yet)').split('\n').slice(-40).join('\n'), bootLog: readFile(BOOT_LOG_PATH, '(no boot log yet)').split('\n').slice(-40).join('\n'),
updateLog: readFile(UPDATE_DAEMON_LOG, '(no update log)').split('\n').slice(-40).join('\n'),
mounts: mountTargets().filter(function (target) { mounts: mountTargets().filter(function (target) {
return target === HOSTS_PATH || target.indexOf('/mnt/lg') === 0; return target === HOSTS_PATH || target.indexOf('/mnt/lg') === 0;
}) })
+105
View File
@@ -0,0 +1,105 @@
#!/bin/sh
# Dumps everything needed to work out why the update popup still appears.
# Read-only: it changes nothing on the TV.
#
# ssh root@<tv-ip> 'sh -s' < tools/tv-diag.sh > tv-diag.txt
#
# Busybox-friendly on purpose - no bashisms, every command guarded.
SLUG=lgupdateblocker
STATE=/var/lib/webosbrew/$SLUG
section() { echo; echo "=================== $* ==================="; }
have() { command -v "$1" >/dev/null 2>&1; }
section "identity"
id
cat /etc/starfish-release 2>/dev/null || echo "(no /etc/starfish-release)"
cat /var/run/nyx/device_info.json 2>/dev/null | head -c 600
echo
uptime
date
section "did our boot hook run this boot?"
ls -l /var/lib/webosbrew/init.d/ 2>&1
echo "--- last 60 lines of $STATE/boot.log ---"
tail -60 "$STATE/boot.log" 2>&1
echo "--- config ---"
cat "$STATE/config.json" 2>&1
echo
echo "--- hosts list the hook uses ---"
cat "$STATE/hosts.txt" 2>&1
section "homebrew channel startup"
ls -la /var/lib/webosbrew/ 2>&1
ls -la /var/luna/preferences/ 2>/dev/null | grep -i webosbrew
echo "--- hbc startup script ---"
ls -l /media/developer/apps/usr/palm/services/org.webosbrew.hbchannel.service/startup.sh 2>&1
section "/etc/hosts"
cat /etc/hosts 2>&1
echo "--- is it bind-mounted? ---"
grep -i hosts /proc/mounts 2>&1
section "does blocking actually work?"
for h in su.lge.com snu.lge.com nsu.lge.com su-ssl.lge.com; do
if have nslookup; then
echo "$h -> $(nslookup "$h" 2>&1 | tail -3 | tr '\n' ' ')"
else
echo "$h -> $(ping -c 1 -W 2 "$h" 2>&1 | head -1)"
fi
done
section "staged firmware / update data"
find /mnt/lg -maxdepth 4 \( -iname '*swupdate*' -o -iname '*epk*' -o -iname '*upgrade*' \) 2>/dev/null | head -40
for d in /mnt/lg/cmn_data/swupdate /mnt/lg/swupdate /mnt/lg/user/swupdate; do
[ -e "$d" ] || continue
echo "--- $d ---"
ls -la "$d" 2>&1
du -sh "$d" 2>&1
done
echo "--- anything mounted over them? ---"
grep -i -E 'swupdate|/mnt/lg' /proc/mounts 2>&1
section "update services"
echo "--- upstart jobs ---"
ls /etc/init 2>/dev/null | grep -i -E 'update|upgrade|swup|ota'
echo "--- luna services ---"
ls /usr/palm/services 2>/dev/null | grep -i -E 'update|upgrade|swup|ota'
ls /var/palm/ls2/roles/prv 2>/dev/null | grep -i -E 'update|upgrade|swup|ota'
echo "--- running processes ---"
ps 2>/dev/null | grep -i -E 'swupdate|updater|upgrade' | grep -v grep
section "update-related settings"
for c in option general network commercial system; do
echo "--- category $c ---"
luna-send -n 1 -f "luna://com.webos.settingsservice/getSystemSettings" \
"{\"category\":\"$c\"}" 2>&1 |
tr ',' '\n' | grep -i -E 'update|upgrade|firmware|ota|epk'
done
section "what the update service itself says"
for svc in com.webos.service.swupdate com.webos.service.swupdater com.webos.service.update com.webos.service.upgrade; do
echo "--- $svc/getStatus ---"
luna-send -n 1 -t 3 -f "luna://$svc/getStatus" '{}' 2>&1 | head -c 500
echo
done
section "boot log: who raised the popup"
for f in /var/log/messages /var/log/syslog; do
[ -f "$f" ] || continue
echo "--- $f (update-related, last 60) ---"
grep -i -E 'swupdate|softwareupdate|upgrade|firmware' "$f" 2>/dev/null | tail -60
done
echo "--- notifications / alerts, last 40 ---"
grep -i -E 'createAlert|notificationmgr|systemAlert' /var/log/messages 2>/dev/null | tail -40
section "luna preferences that mention update"
grep -ril update /var/luna/preferences/ 2>/dev/null | head -20
for f in $(grep -ril update /var/luna/preferences/ 2>/dev/null | head -20); do
echo "--- $f ---"
head -c 300 "$f"
echo
done
section "done"
+98
View File
@@ -0,0 +1,98 @@
#!/bin/sh
# Second probe: find where the pending-update record lives, what the real
# auto-update switch is, and whether /etc/hosts is honoured.
# Read-only: it changes nothing on the TV.
#
# ssh root@<tv-ip> 'sh -s' < tools/tv-diag2.sh | tee tv-diag2.txt
section() { echo; echo "=================== $* ==================="; }
have() { command -v "$1" >/dev/null 2>&1; }
# luna-send writes progress chatter to stderr; keep only the payload
lsend() { luna-send -n 1 -t 5 -f "luna://$1" "$2" 2>/dev/null; }
section "is /etc/hosts actually honoured? (getaddrinfo, not DNS)"
for h in su.lge.com snu.lge.com nsu.lge.com su-ssl.lge.com; do
echo "--- $h ---"
ping -c 1 -W 3 "$h" 2>&1 | head -2
done
echo "--- can it still reach an update server over http? ---"
have wget && wget -q -T 5 -O - http://su.lge.com/ 2>&1 | head -c 200
echo
section "com.webos.service.update - what does it expose?"
grep -rl "com.webos.service.update" /usr/share/luna-service2/ 2>/dev/null | head -10
for f in $(grep -rl "com.webos.service.update" /usr/share/luna-service2/api-permissions.d/ 2>/dev/null | head -4); do
echo "--- $f ---"
head -c 1500 "$f"
echo
done
echo "--- service file ---"
grep -rh "com.webos.service.update" /usr/share/luna-service2/services.d/ 2>/dev/null | head -10
section "com.webos.service.update - current state"
for m in getStatus getSettings getUpdateInfo getCurrentSWInformation getUpdateStatus checkUpdate; do
echo "--- /$m ---"
lsend "com.webos.service.update/$m" '{}' | head -c 700
echo
done
section "settingsservice: every key mentioning update (via db8)"
lsend com.webos.service.db/find '{"query":{"from":"com.webos.settings.system:1"}}' |
tr ',' '\n' | grep -i -E 'update|upgrade|firmware|ota|nsu|epk' | head -60
echo "--- same for the description kind (tells us allowed values) ---"
lsend com.webos.service.db/find '{"query":{"from":"com.webos.settings.desc.system:1"}}' |
tr ',' '\n' | grep -i -E 'update|upgrade|firmware|ota|nsu' | head -60
section "settingsservice: direct reads"
for k in autoUpdate appUpdateMode swUpdateMode updateMode; do
echo "--- option/$k ---"
lsend com.webos.settingsservice/getSystemSettings "{\"category\":\"option\",\"keys\":[\"$k\"]}" | head -c 300
echo
done
echo "--- no category, just the key ---"
lsend com.webos.settingsservice/getSystemSettings '{"keys":["autoUpdate","appUpdateMode"]}' | head -c 400
echo
section "luna preferences: full update-related keys"
for f in /var/luna/preferences/option /var/luna/preferences/general /var/luna/preferences/network; do
[ -f "$f" ] || continue
echo "--- $f ---"
tr ',' '\n' < "$f" | grep -i -E 'update|upgrade|firmware|ota|nsu|epk'
done
echo "--- any other preference file with an update key ---"
for f in /var/luna/preferences/*; do
[ -f "$f" ] || continue
hits=$(tr ',' '\n' < "$f" 2>/dev/null | grep -i -E '"[a-z]*(update|upgrade|nsu)[a-z]*"' | head -5)
[ -n "$hits" ] && { echo "--- $f ---"; echo "$hits"; }
done
section "where is the pending update recorded?"
echo "--- files under /mnt/lg mentioning swupdate/nsu/epk ---"
find /mnt/lg -maxdepth 3 -type f -size -2M 2>/dev/null |
while read -r f; do
if grep -l -i -E 'nsuimage|swupdate|epk_|otaVersion' "$f" >/dev/null 2>&1; then
echo "$f ($(wc -c < "$f") bytes, $(date -r "$f" 2>/dev/null))"
fi
done | head -20
echo "--- update-ish paths anywhere under /mnt/lg and /var ---"
find /mnt/lg /var -maxdepth 4 \( -iname '*swupdate*' -o -iname '*nsu*' -o -iname '*otaid*' \) 2>/dev/null | head -30
echo "--- tvservice config dirs ---"
ls -la /mnt/lg/cmn_data/ 2>/dev/null | head -60
section "the popup at boot: full log window"
ls -la /var/log/ 2>/dev/null | head -20
echo "--- first 120 update/alert lines in the current log ---"
grep -i -E 'swupdate|softwareupdate|createAlert|notification|popup|otaid' /var/log/messages 2>/dev/null | head -120
section "who is running the updater"
ps 2>/dev/null | grep -i -E 'broadcast|update' | grep -v grep
echo "--- upstart job contents ---"
for j in /etc/init/update.conf /etc/init/software-update-complete.conf; do
[ -f "$j" ] || continue
echo "--- $j ---"
cat "$j"
done
echo "--- are they actually stopped? ---"
initctl list 2>/dev/null | grep -i -E 'update|broadcast'
section "done"