init commit
Build / build (push) Failing after 1m50s

This commit is contained in:
Rene Kievits
2026-09-05 22:27:44 +02:00
commit b137bf9eda
21 changed files with 8371 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
name: Build
on:
push:
branches: ['**']
tags: ['v*']
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install ares CLI
run: npm install
- name: Build
run: npm run build
- name: Package IPK
run: npm run package
- name: Generate manifest and repository index
run: npm run manifest
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: lg-update-blocker
path: dist/*
# Tag a commit with v<version> (matching package.json) to publish a
# release. The repository URL for the Homebrew Channel stays stable:
# https://github.com/<owner>/<repo>/releases/latest/download/apps.json
- name: Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true
body: |
Install through the Homebrew Channel by adding this repository once
(Homebrew Channel → Settings → Add repository):
```
https://github.com/${{ github.repository }}/releases/latest/download/apps.json
```
Or install the `.ipk` below manually with
`ares-install <file>.ipk`.
+5
View File
@@ -0,0 +1,5 @@
node_modules/
build/
dist/
*.ipk
.DS_Store
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 LG Update Blocker contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+144
View File
@@ -0,0 +1,144 @@
<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.
![screenshot](docs/screenshot.png)
## Why the popup keeps coming back
The Homebrew Channel's own *Block system updates* toggle only writes four
hostnames into `/etc/hosts` at boot. That is not enough in practice:
* it misses several update hostnames (`nsu`, `*-ssl`, the CDN aliases),
* it does nothing about a firmware image the TV **already downloaded** — a
staged update keeps prompting even when the update servers are unreachable,
* it leaves the auto-update settings and the update services alone.
This app covers all of that, and re-applies everything on each boot (webOS
resets `/`, `/tmp` and the mount namespace on every start).
## 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 |
| **Delete staged firmware** | Wipes the update image staged in `/mnt/lg/cmn_data/swupdate` | 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 |
| **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
settings from the backup it made.
## 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*:
```
https://github.com/<owner>/<repo>/releases/latest/download/apps.json
```
The app then shows up in the Homebrew Channel list, and every new release you
tag is picked up automatically (that URL always points at the newest release).
### Manually
Download the `.ipk` from the release and:
```sh
ares-install com.rkievits.lgupdateblocker_1.0.0_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/lgupdateblocker` | Boot hook, run by the Homebrew Channel startup script; re-applies the enabled layers |
| `/var/lib/webosbrew/lgupdateblocker/` | `config.json`, `hosts.txt`, `settings-backup.json`, `boot.log` |
| `/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 which jobs were stopped.
* **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` builds on every push. 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
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
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1920, initial-scale=1" />
<title>__TITLE__</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="screen">
<header class="header">
<div>
<h1>__TITLE__</h1>
<p class="subtitle">Stops the LG firmware update popup on rooted webOS TVs</p>
</div>
<div class="badges">
<span id="badge-root" class="badge badge-unknown">checking root…</span>
<span id="badge-version" class="badge">v__VERSION__</span>
</div>
</header>
<div class="columns">
<main class="panel">
<h2>Protection layers</h2>
<div id="toggles" class="toggles"></div>
<h2>Actions</h2>
<div id="actions" class="actions"></div>
</main>
<aside class="panel">
<h2>Status</h2>
<dl id="status" class="status"></dl>
<h2>Log</h2>
<pre id="log" class="log"></pre>
</aside>
</div>
<footer class="footer">
<span>▲▼ move · OK select · BACK exit</span>
<span id="hint"></span>
</footer>
</div>
<script src="index.js"></script>
</body>
</html>
+407
View File
@@ -0,0 +1,407 @@
/**
* LG Update Blocker - frontend.
*
* No frameworks and no build step: Luna calls go straight through
* PalmServiceBridge, which every webOS webapp gets injected for free.
*/
(function () {
'use strict';
var SERVICE = 'luna://__SERVICE_ID__';
var HBCHANNEL = 'luna://org.webosbrew.hbchannel.service';
var HBCHANNEL_ELEVATE =
'/media/developer/apps/usr/palm/services/org.webosbrew.hbchannel.service/elevate-service';
var TOGGLES = [
{
key: 'blockHosts',
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'
},
{
key: 'purgeCache',
title: 'Delete staged firmware',
desc: 'Wipes an already downloaded update - the usual reason the popup keeps returning'
},
{
key: 'lockCache',
title: 'Lock the staging folder',
desc: 'Mounts an empty read-only folder over it so no update can be staged again'
},
{
key: 'disableSettings',
title: 'Turn off auto-update settings',
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'
}
];
var ACTIONS = [
{ key: 'apply', label: 'Apply protection', primary: true },
{ key: 'purge', label: 'Purge staged update' },
{ key: 'revert', label: 'Remove protection' },
{ key: 'refresh', label: 'Refresh status' },
{ key: 'diagnostics', label: 'Diagnostics' }
];
var config = {
blockHosts: true,
purgeCache: true,
lockCache: false,
disableSettings: true,
stopServices: false
};
var focusables = [];
var focusIndex = 0;
var busy = false;
var bridges = [];
/* ----------------------------------------------------------- luna calls */
function luna(uri, params, onSuccess, onFailure) {
if (typeof PalmServiceBridge === 'undefined') {
if (onFailure) onFailure({ returnValue: false, errorText: 'no Luna bus (not running on a TV)' });
return;
}
var bridge = new PalmServiceBridge();
bridges.push(bridge);
bridge.onservicecallback = function (raw) {
var index = bridges.indexOf(bridge);
if (index !== -1) bridges.splice(index, 1);
var response;
try {
response = JSON.parse(raw);
} catch (err) {
response = { returnValue: false, errorText: 'malformed response: ' + raw };
}
if (response.returnValue === false || response.errorText) {
if (onFailure) onFailure(response);
} else if (onSuccess) {
onSuccess(response);
}
};
bridge.call(uri, JSON.stringify(params || {}));
}
/* ------------------------------------------------------------------- ui */
function el(id) {
return document.getElementById(id);
}
function log(text) {
var pane = el('log');
var stamp = new Date().toTimeString().slice(0, 8);
pane.textContent += '[' + stamp + '] ' + text + '\n';
pane.scrollTop = pane.scrollHeight;
}
function logLines(lines) {
(lines || []).forEach(function (line) {
log(line);
});
}
function setHint(text, isBusy) {
var hint = el('hint');
hint.textContent = text || '';
hint.className = isBusy ? 'busy' : '';
}
function setBusy(state, text) {
busy = state;
setHint(text || (state ? 'working…' : ''), state);
}
function bytes(value) {
if (!value) return '0 B';
var units = ['B', 'kB', 'MB', 'GB'];
var index = 0;
var size = value;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return (index === 0 ? size : size.toFixed(1)) + ' ' + units[index];
}
function buildUi() {
var toggles = el('toggles');
TOGGLES.forEach(function (toggle) {
var row = document.createElement('div');
row.className = 'row';
row.innerHTML =
'<div class="text"><div class="title"></div><div class="desc"></div></div>' +
'<div class="state"></div>';
row.querySelector('.title').textContent = toggle.title;
row.querySelector('.desc').textContent = toggle.desc;
row.dataset.toggle = toggle.key;
row.onActivate = function () {
config[toggle.key] = !config[toggle.key];
renderToggles();
log((config[toggle.key] ? 'enabled: ' : 'disabled: ') + toggle.title +
' (press Apply protection to write it to the TV)');
};
toggles.appendChild(row);
focusables.push(row);
});
var actions = el('actions');
ACTIONS.forEach(function (action) {
var button = document.createElement('div');
button.className = 'button' + (action.primary ? ' primary' : '');
button.textContent = action.label;
button.onActivate = function () {
runAction(action.key);
};
actions.appendChild(button);
focusables.push(button);
});
renderToggles();
renderFocus();
}
function renderToggles() {
TOGGLES.forEach(function (toggle, index) {
var row = focusables[index];
var on = !!config[toggle.key];
row.className = 'row' + (on ? ' on' : '') + (index === focusIndex ? ' focused' : '');
row.querySelector('.state').textContent = on ? 'ON' : 'OFF';
});
}
function renderFocus() {
focusables.forEach(function (node, index) {
var focused = index === focusIndex;
if (focused) {
if (node.className.indexOf('focused') === -1) node.className += ' focused';
if (node.scrollIntoView) node.scrollIntoView(false);
} else {
node.className = node.className.replace(/\s*focused/g, '');
}
});
}
function move(delta) {
focusIndex = (focusIndex + delta + focusables.length) % focusables.length;
renderFocus();
}
function badge(id, text, kind) {
var node = el(id);
node.textContent = text;
node.className = 'badge' + (kind ? ' badge-' + kind : '');
}
function renderStatus(status) {
var list = el('status');
list.innerHTML = '';
function row(label, value) {
var wrapper = document.createElement('div');
var dt = document.createElement('dt');
var dd = document.createElement('dd');
dt.textContent = label;
dd.textContent = value;
wrapper.appendChild(dt);
wrapper.appendChild(dd);
list.appendChild(wrapper);
}
var staged = 0;
var stagedDirs = 0;
var locked = 0;
(status.cache || []).forEach(function (entry) {
stagedDirs += 1;
staged += entry.bytes || 0;
if (entry.locked) locked += 1;
});
row('Service user', status.root ? 'root' : 'uid ' + status.uid + ' (not elevated)');
row('Hosts entries', status.hosts.blockedDomains + ' / ' + status.hosts.totalDomains +
(status.hosts.bindMounted ? ' (bind-mounted)' : ''));
row('Staging folders', stagedDirs ? stagedDirs + ' found' : 'none on this TV');
row('Staged firmware', stagedDirs ? bytes(staged) : '-');
row('Staging locked', stagedDirs ? locked + ' / ' + stagedDirs : '-');
row('Update jobs found', (status.jobs || []).length ? status.jobs.join(', ') : 'none');
row('Boot hook', status.bootHook.installed ? 'installed' : 'not installed');
var settings = status.settings || [];
if (settings.length) {
settings.forEach(function (entry) {
row(entry.category + '/' + entry.key, JSON.stringify(entry.value));
});
} else {
row('Update settings', 'none exposed');
}
badge('badge-root', status.root ? 'root' : 'no root', status.root ? 'ok' : 'bad');
badge('badge-version', 'v' + status.version);
if (status.config) {
Object.keys(config).forEach(function (key) {
if (typeof status.config[key] === 'boolean') config[key] = status.config[key];
});
renderToggles();
}
}
/* -------------------------------------------------------------- actions */
function refresh(onDone) {
luna(SERVICE + '/status', {}, function (status) {
renderStatus(status);
if (onDone) onDone(status);
}, function (err) {
log('status failed: ' + (err.errorText || JSON.stringify(err)));
badge('badge-root', 'service error', 'bad');
if (onDone) onDone(null);
});
}
function runAction(key) {
if (busy) return;
if (key === 'refresh') {
setBusy(true, 'refreshing…');
refresh(function () {
setBusy(false);
log('status refreshed');
});
return;
}
if (key === 'diagnostics') {
setBusy(true, 'collecting diagnostics…');
luna(SERVICE + '/diagnostics', {}, function (res) {
setBusy(false);
log('--- diagnostics ---');
log('os: ' + JSON.stringify(res.osInfo));
log('mounts: ' + JSON.stringify(res.mounts));
log('settings: ' + JSON.stringify(res.settings));
log('hosts (tail):\n' + res.hostsFile);
log('boot hook log (tail):\n' + res.bootLog);
log('--- end ---');
}, function (err) {
setBusy(false);
log('diagnostics failed: ' + (err.errorText || JSON.stringify(err)));
});
return;
}
var uri = SERVICE + '/' + key;
var params = key === 'apply' ? config : {};
setBusy(true, key === 'revert' ? 'removing…' : 'applying…');
luna(uri, params, function (res) {
setBusy(false);
logLines(res.log);
if (res.status) renderStatus(res.status);
else refresh();
}, function (err) {
setBusy(false);
logLines(err.log);
log('failed: ' + (err.errorText || JSON.stringify(err)));
});
}
/* ------------------------------------------------------------ elevation */
function elevate(onDone) {
log('service is not running as root, asking the Homebrew Channel to elevate it…');
function afterElevation() {
// The running instance keeps its old (unprivileged) process; quitting it
// makes the bus start a fresh, elevated one on the next call.
luna(SERVICE + '/quit', {}, function () {
window.setTimeout(recheck, 2500);
}, function () {
window.setTimeout(recheck, 2500);
});
}
function recheck() {
refresh(function (status) {
if (status && status.root) {
log('service elevated - ready');
} else {
log('still not elevated. Open the Homebrew Channel, make sure "Root status" says ' +
'"ok", then relaunch this app.');
}
setBusy(false);
if (onDone) onDone();
});
}
luna(HBCHANNEL + '/elevateService', { id: '__SERVICE_ID__' }, function () {
log('elevateService succeeded, restarting our service…');
afterElevation();
}, function (err) {
log('elevateService unavailable (' + (err.errorText || 'unknown') + '), trying exec fallback…');
luna(HBCHANNEL + '/exec', { command: HBCHANNEL_ELEVATE + ' __SERVICE_ID__' }, function () {
log('elevate-service executed, restarting our service…');
afterElevation();
}, function (execErr) {
log('could not elevate: ' + (execErr.errorText || JSON.stringify(execErr)));
log('Is the Homebrew Channel installed and rooted? Its service must be elevated first.');
setBusy(false);
if (onDone) onDone();
});
});
}
/* ---------------------------------------------------------------- input */
document.addEventListener('keydown', function (event) {
switch (event.keyCode) {
case 38: // up
move(-1);
break;
case 40: // down
move(1);
break;
case 37: // left
move(-1);
break;
case 39: // right
move(1);
break;
case 13: // OK
case 32: // space (keyboard/dev)
if (!busy && focusables[focusIndex] && focusables[focusIndex].onActivate) {
focusables[focusIndex].onActivate();
}
break;
case 461: // back
case 27: // esc
window.close();
break;
default:
return;
}
event.preventDefault();
});
/* ----------------------------------------------------------------- boot */
buildUi();
log('LG Update Blocker v__VERSION__');
setBusy(true, 'checking service…');
refresh(function (status) {
if (!status) {
setBusy(false);
return;
}
if (status.root) {
setBusy(false);
log('service is running as root - ready');
} else {
elevate();
}
});
})();
+234
View File
@@ -0,0 +1,234 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0d1014;
color: #e6e9ee;
font-family: "LG Smart UI", "Museo Sans", Helvetica, Arial, sans-serif;
font-size: 24px;
}
.screen {
/* TV overscan safe area */
padding: 54px 90px 40px;
height: 100%;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding-bottom: 24px;
border-bottom: 2px solid #232a33;
}
h1 {
font-size: 46px;
font-weight: 600;
letter-spacing: 0.5px;
}
h2 {
font-size: 24px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
color: #7c8899;
margin: 26px 0 14px;
}
h2:first-child {
margin-top: 0;
}
.subtitle {
color: #8b95a5;
font-size: 22px;
margin-top: 6px;
}
.badges {
display: flex;
gap: 12px;
}
.badge {
padding: 8px 18px;
border-radius: 999px;
background: #1a2029;
color: #9aa5b5;
font-size: 20px;
white-space: nowrap;
}
.badge-ok {
background: #123021;
color: #4ade80;
}
.badge-bad {
background: #35161a;
color: #f87171;
}
.badge-unknown {
background: #2a2413;
color: #fbbf24;
}
.columns {
display: flex;
gap: 40px;
flex: 1;
min-height: 0;
padding-top: 26px;
}
.panel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel:last-child {
max-width: 720px;
}
.toggles,
.actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.actions {
flex-direction: row;
flex-wrap: wrap;
}
.row {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 22px;
border: 3px solid transparent;
border-radius: 12px;
background: #151b23;
}
.row .text {
flex: 1;
min-width: 0;
}
.row .title {
font-size: 25px;
}
.row .desc {
font-size: 19px;
color: #8b95a5;
margin-top: 4px;
}
.row .state {
font-size: 22px;
font-weight: 600;
padding: 6px 16px;
border-radius: 8px;
background: #232b36;
color: #94a3b8;
min-width: 92px;
text-align: center;
}
.row.on .state {
background: #14361f;
color: #4ade80;
}
.button {
padding: 16px 28px;
border: 3px solid transparent;
border-radius: 12px;
background: #1d2530;
font-size: 24px;
}
.button.primary {
background: #7f1d24;
color: #ffe4e6;
}
.focused {
border-color: #f43f5e;
background: #222c38;
}
.button.primary.focused {
background: #b91c2c;
}
.status {
font-size: 21px;
line-height: 1.5;
}
.status div {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
border-bottom: 1px solid #1b2029;
}
.status dt {
color: #8b95a5;
}
.status dd {
text-align: right;
color: #dbe2ea;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
}
.log {
flex: 1;
min-height: 120px;
overflow-y: auto;
background: #0a0d11;
border: 1px solid #1b2029;
border-radius: 10px;
padding: 14px 18px;
font-family: "Andale Mono", monospace;
font-size: 18px;
line-height: 1.45;
color: #9fb0c3;
white-space: pre-wrap;
word-break: break-word;
}
.footer {
display: flex;
justify-content: space-between;
padding-top: 18px;
color: #6b7686;
font-size: 20px;
}
.busy {
color: #fbbf24;
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "__APP_ID__",
"version": "__VERSION__",
"vendor": "rkievits",
"type": "web",
"main": "index.html",
"title": "__TITLE__",
"appDescription": "Blocks the recurring LG webOS firmware update popup (rooted TVs, Homebrew Channel required)",
"icon": "icon.png",
"largeIcon": "largeIcon.png",
"iconColor": "#1b1f24",
"bgColor": "#101418",
"disableBackHistoryAPI": true,
"requiredPermissions": [
"time.query",
"activity.operation"
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
<!-- Kept to primitives with explicit fills: this file is also rasterised by
ImageMagick's built-in SVG renderer, which ignores transforms. -->
<rect x="0" y="0" width="400" height="400" rx="72" ry="72" fill="#151b23"/>
<circle cx="200" cy="200" r="157" fill="#f43f5e"/>
<circle cx="200" cy="200" r="123" fill="#151b23"/>
<rect x="182" y="98" width="36" height="112" rx="8" ry="8" fill="#e6e9ee"/>
<polygon points="200,266 133,190 267,190" fill="#e6e9ee"/>
<rect x="128" y="286" width="144" height="30" rx="12" ry="12" fill="#e6e9ee"/>
<polygon points="297.6,75.6 324.4,102.4 102.4,324.4 75.6,297.6" fill="#f43f5e"/>
</svg>

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+23
View File
@@ -0,0 +1,23 @@
<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>
<p>It applies up to five reversible layers and re-applies them on every boot
through a Homebrew Channel startup hook:</p>
<ul>
<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>
<li><b>Delete staged firmware</b> - wipes /mnt/lg/cmn_data/swupdate. An update
that was already downloaded keeps prompting even when the servers are
unreachable, which is why blocking alone often is not enough.</li>
<li><b>Lock the staging folder</b> - bind-mounts an empty read-only directory
over it so nothing can be staged again.</li>
<li><b>Turn off auto-update settings</b> - switches off every update related
key that com.webos.settingsservice exposes on your firmware, remembering
the original values.</li>
<li><b>Stop update services at boot</b> - optional, stops the update upstart
jobs found on the TV.</li>
</ul>
<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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

+6295
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "com.rkievits.lgupdateblocker",
"version": "1.0.0",
"title": "LG Update Blocker",
"description": "Blocks the recurring LG webOS firmware update popup on rooted TVs",
"private": true,
"scripts": {
"build": "node tools/build.js",
"package": "ares-package build/app build/service -n -o dist",
"manifest": "node tools/gen-manifest.js",
"dist": "npm run build && npm run package && npm run manifest",
"deploy": "ares-install dist/${npm_package_name}_${npm_package_version}_all.ipk",
"launch": "ares-launch ${npm_package_name}",
"clean": "rm -rf build dist"
},
"license": "MIT",
"devDependencies": {
"@webos-tools/cli": "^3.2.1"
}
}
+26
View File
@@ -0,0 +1,26 @@
# Hostnames used by LG's TV firmware update ("Network Software Update") stack.
# One hostname per line, "#" starts a comment. Everything listed here is
# pointed at 127.0.0.1 / ::1 in /etc/hosts while blocking is enabled.
#
# Only firmware-update endpoints belong in this file. Do NOT add lgtvsdp.com
# hosts: those carry the app store, app updates and several other services, and
# blocking them breaks more than the update nag. (For forced *app* updates use
# the separate "LG App Update Blocker" homebrew app.)
# Update check / manifest servers
snu.lge.com
su.lge.com
nsu.lge.com
# TLS variants
snu-ssl.lge.com
su-ssl.lge.com
nsu-ssl.lge.com
# Development/staging variants that some firmware builds fall back to
snu-dev.lge.com
su-dev.lge.com
# CDN hostnames the update servers are CNAMEd to
snu.lge.com.usgcac.cdnetworks.net
su.lge.com.usgcac.cdnetworks.net
+6
View File
@@ -0,0 +1,6 @@
{
"name": "__SERVICE_ID__",
"version": "__VERSION__",
"description": "__TITLE__ service",
"main": "service.js"
}
+871
View File
@@ -0,0 +1,871 @@
/**
* LG Update Blocker - root service.
*
* Everything this service does is reversible and is applied in layers:
*
* 1. hosts - point LG's firmware update servers at 127.0.0.1
* 2. purge - delete the firmware image the TV already staged
* 3. lock - bind-mount an empty read-only dir over the staging dir
* 4. settings - switch off the update-related com.webos.settingsservice keys
* 5. services - stop the update related upstart jobs (advanced, opt-in)
*
* / 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
* /var/lib/webosbrew/init.d (run by the Homebrew Channel startup script).
*
* Written in ES5 without dependencies - it runs on the TV's own node with the
* platform-provided webos-service module and nothing else.
*/
'use strict';
var fs = require('fs');
var path = require('path');
var childProcess = require('child_process');
var Service = require('webos-service');
var pkgInfo = require('./package.json');
var service = new Service(pkgInfo.name);
service.activityManager.idleTimeout = 300;
var SLUG = 'lgupdateblocker';
var STATE_DIR = '/var/lib/webosbrew/' + SLUG;
var CONFIG_PATH = STATE_DIR + '/config.json';
var HOSTS_LIST_PATH = STATE_DIR + '/hosts.txt';
var SETTINGS_BACKUP_PATH = STATE_DIR + '/settings-backup.json';
var EMPTY_DIR = STATE_DIR + '/empty';
var BOOT_LOG_PATH = STATE_DIR + '/boot.log';
var BOOT_HOOK_DIR = '/var/lib/webosbrew/init.d';
var BOOT_HOOK_PATH = BOOT_HOOK_DIR + '/' + SLUG;
var HOSTS_PATH = '/etc/hosts';
var HOSTS_TMP = '/tmp/' + SLUG + '-hosts';
var MARKER_BEGIN = '# >>> lg-update-blocker >>>';
var MARKER_END = '# <<< lg-update-blocker <<<';
var BUNDLED_HOSTS = path.join(__dirname, 'data', 'lg-update-hosts.txt');
/* Directories where the TV stages a downloaded firmware image. A staged image
* keeps the "update is ready" popup coming back even when the update servers
* are unreachable. Only paths below /mnt/lg are ever touched. */
var CACHE_DIRS = [
'/mnt/lg/cmn_data/swupdate',
'/mnt/lg/swupdate',
'/mnt/lg/cmn_data/var/palm/data/com.webos.service.swupdate'
];
var CACHE_GUARD = '/mnt/lg/';
var UPSTART_DIR = '/etc/init';
var JOB_PATTERN = /(swupdate|softwareupdate|firmware|fota|update|upgrade|nsu)/i;
var SETTINGS_CATEGORIES = ['option', 'general', 'network', 'commercial'];
var SETTINGS_KEY_PATTERN = /(update|upgrade|firmware|ota)/i;
var DEFAULT_CONFIG = {
blockHosts: true,
purgeCache: true,
lockCache: false,
disableSettings: true,
stopServices: false
};
var CONFIG_KEYS = Object.keys(DEFAULT_CONFIG);
/* ------------------------------------------------------------------ utils */
function isRoot() {
return typeof process.getuid === 'function' && process.getuid() === 0;
}
function exists(target) {
try {
fs.statSync(target);
return true;
} catch (err) {
return false;
}
}
function isDirectory(target) {
try {
return fs.statSync(target).isDirectory();
} catch (err) {
return false;
}
}
function mkdirp(dir) {
if (isDirectory(dir)) return;
mkdirp(path.dirname(dir));
try {
fs.mkdirSync(dir);
} catch (err) {
if (!isDirectory(dir)) throw err;
}
}
function readFile(target, fallback) {
try {
return fs.readFileSync(target, 'utf8');
} catch (err) {
return fallback;
}
}
function readJson(target, fallback) {
try {
return JSON.parse(fs.readFileSync(target, 'utf8'));
} catch (err) {
return fallback;
}
}
function writeJson(target, value) {
mkdirp(path.dirname(target));
fs.writeFileSync(target, JSON.stringify(value, null, 2) + '\n');
}
function sh(command) {
try {
var out = childProcess.execSync(command + ' 2>&1', { encoding: 'utf8', timeout: 30000 });
return { ok: true, output: String(out || '').trim() };
} catch (err) {
var output = (err && (err.stdout || err.message)) || '';
return { ok: false, output: String(output).trim() };
}
}
function Log() {
this.lines = [];
}
Log.prototype.add = function (line) {
console.info(line);
this.lines.push(line);
return this;
};
/* ----------------------------------------------------------------- config */
function readConfig() {
var stored = readJson(CONFIG_PATH, {});
var config = {};
CONFIG_KEYS.forEach(function (key) {
config[key] = typeof stored[key] === 'boolean' ? stored[key] : DEFAULT_CONFIG[key];
});
return config;
}
function mergeConfig(config, payload) {
var merged = {};
CONFIG_KEYS.forEach(function (key) {
merged[key] = typeof payload[key] === 'boolean' ? payload[key] : config[key];
});
return merged;
}
function anyEnabled(config) {
return CONFIG_KEYS.some(function (key) {
return config[key];
});
}
/* ------------------------------------------------------------------ hosts */
function readDomains() {
var raw = readFile(BUNDLED_HOSTS, null);
if (raw === null) raw = readFile(HOSTS_LIST_PATH, '');
return raw
.split('\n')
.map(function (line) {
return line.replace(/#.*$/, '').trim();
})
.filter(function (line) {
return line.length > 0 && line.indexOf(' ') === -1;
});
}
function mountTargets() {
return readFile('/proc/mounts', '')
.split('\n')
.map(function (line) {
return line.split(' ')[1];
})
.filter(Boolean);
}
function isMountPoint(target) {
return mountTargets().indexOf(target) !== -1;
}
function hostsWritable() {
try {
fs.appendFileSync(HOSTS_PATH, '');
return true;
} catch (err) {
return false;
}
}
/* / is mounted read-only, so /etc/hosts gets replaced by a bind mount of a
* writable copy in /tmp - the same trick the Homebrew Channel startup script
* uses (and it happily stacks with it). */
function makeHostsWritable(log) {
if (hostsWritable()) return true;
try {
fs.writeFileSync(HOSTS_TMP, readFile(HOSTS_PATH, ''));
fs.chmodSync(HOSTS_TMP, parseInt('644', 8));
} catch (err) {
log.add('! could not stage a writable hosts file: ' + err.message);
return false;
}
var res = sh('mount --bind ' + HOSTS_TMP + ' ' + HOSTS_PATH);
if (!res.ok || !hostsWritable()) {
log.add('! bind mount of ' + HOSTS_PATH + ' failed: ' + res.output);
return false;
}
log.add('bind-mounted a writable ' + HOSTS_PATH + ' (from ' + HOSTS_TMP + ')');
return true;
}
function stripHostsBlock(content) {
var out = [];
var inside = false;
content.split('\n').forEach(function (line) {
if (line.indexOf(MARKER_BEGIN) === 0) {
inside = true;
return;
}
if (inside) {
if (line.indexOf(MARKER_END) === 0) inside = false;
return;
}
out.push(line);
});
return out.join('\n').replace(/\n{3,}$/, '\n');
}
function buildHostsBlock(domains) {
var lines = [MARKER_BEGIN];
domains.forEach(function (domain) {
lines.push('127.0.0.1 ' + domain);
lines.push('::1 ' + domain);
});
lines.push(MARKER_END);
return lines.join('\n') + '\n';
}
function hostsBlockedDomains() {
var content = readFile(HOSTS_PATH, '');
if (content.indexOf(MARKER_BEGIN) === -1) return 0;
return readDomains().filter(function (domain) {
return content.indexOf(' ' + domain) !== -1;
}).length;
}
function applyHosts(log) {
var domains = readDomains();
if (!domains.length) {
log.add('! no update domains found to block');
return false;
}
if (!makeHostsWritable(log)) return false;
var content = stripHostsBlock(readFile(HOSTS_PATH, ''));
if (content.length && content.charAt(content.length - 1) !== '\n') content += '\n';
fs.writeFileSync(HOSTS_PATH, content + buildHostsBlock(domains));
log.add('blocked ' + domains.length + ' update hostnames in ' + HOSTS_PATH);
return true;
}
function removeHosts(log) {
var content = readFile(HOSTS_PATH, '');
if (content.indexOf(MARKER_BEGIN) === -1) {
log.add('no hosts entries to remove');
return true;
}
if (!hostsWritable()) {
log.add('! ' + HOSTS_PATH + ' is not writable, entries stay until reboot');
return false;
}
fs.writeFileSync(HOSTS_PATH, stripHostsBlock(content));
log.add('removed update hostnames from ' + HOSTS_PATH);
return true;
}
/* ------------------------------------------------------- staged firmware */
function rmrf(target) {
if (target.indexOf(CACHE_GUARD) !== 0) throw new Error('refusing to delete ' + target);
var stat;
try {
stat = fs.lstatSync(target);
} catch (err) {
return;
}
if (stat.isDirectory()) {
fs.readdirSync(target).forEach(function (entry) {
rmrf(path.join(target, entry));
});
fs.rmdirSync(target);
} else {
fs.unlinkSync(target);
}
}
function dirBytes(target, depth) {
var total = 0;
var stat;
try {
stat = fs.lstatSync(target);
} catch (err) {
return 0;
}
if (!stat.isDirectory()) return stat.size;
if (depth > 8) return 0;
fs.readdirSync(target).forEach(function (entry) {
total += dirBytes(path.join(target, entry), depth + 1);
});
return total;
}
function cacheStatus() {
return CACHE_DIRS.filter(isDirectory).map(function (dir) {
var entries = [];
try {
entries = fs.readdirSync(dir);
} catch (err) {
entries = [];
}
return {
path: dir,
entries: entries,
bytes: dirBytes(dir, 0),
locked: isMountPoint(dir)
};
});
}
function purgeCache(log) {
var dirs = CACHE_DIRS.filter(isDirectory);
if (!dirs.length) {
log.add('no firmware staging directory present on this TV');
return;
}
dirs.forEach(function (dir) {
if (isMountPoint(dir)) {
log.add(dir + ' is locked (empty read-only mount), nothing staged');
return;
}
var entries;
try {
entries = fs.readdirSync(dir);
} catch (err) {
log.add('! cannot read ' + dir + ': ' + err.message);
return;
}
if (!entries.length) {
log.add(dir + ' is already empty');
return;
}
var removed = 0;
entries.forEach(function (entry) {
try {
rmrf(path.join(dir, entry));
removed += 1;
} catch (err) {
log.add('! could not delete ' + entry + ' in ' + dir + ': ' + err.message);
}
});
log.add('purged ' + removed + '/' + entries.length + ' staged item(s) from ' + dir);
});
}
function lockCache(log) {
var dirs = CACHE_DIRS.filter(isDirectory);
if (!dirs.length) {
log.add('no firmware staging directory to lock');
return;
}
mkdirp(EMPTY_DIR);
dirs.forEach(function (dir) {
if (isMountPoint(dir)) {
log.add(dir + ' is already locked');
return;
}
var res = sh('mount -o bind,ro ' + EMPTY_DIR + ' ' + dir);
if (!res.ok) {
log.add('! could not lock ' + dir + ': ' + res.output);
return;
}
// Older busybox mount ignores "ro" on the initial bind, so remount.
sh('mount -o bind,remount,ro ' + EMPTY_DIR + ' ' + dir);
log.add('locked ' + dir + ' (empty, read-only)');
});
}
function unlockCache(log) {
CACHE_DIRS.forEach(function (dir) {
if (!isMountPoint(dir)) return;
var res = sh('umount ' + dir);
log.add(res.ok ? 'unlocked ' + dir : '! could not unlock ' + dir + ': ' + res.output);
});
}
/* ----------------------------------------------------------- upstart jobs */
function discoverJobs() {
var entries;
try {
entries = fs.readdirSync(UPSTART_DIR);
} catch (err) {
return [];
}
return entries
.filter(function (entry) {
return /\.conf$/.test(entry) && JOB_PATTERN.test(entry);
})
.map(function (entry) {
return entry.replace(/\.conf$/, '');
})
.sort();
}
function stopJobs(jobs, log) {
if (!jobs.length) {
log.add('no update-related upstart jobs found');
return;
}
jobs.forEach(function (job) {
var res = sh('initctl stop ' + job);
if (!res.ok) res = sh('stop ' + job);
log.add(res.ok ? 'stopped upstart job ' + job : '! could not stop ' + job + ': ' + res.output);
});
}
/* --------------------------------------------------------------- settings */
function callLuna(uri, params, callback) {
var done = false;
var timer = setTimeout(function () {
if (done) return;
done = true;
callback({ returnValue: false, errorText: 'timed out' });
}, 4000);
function finish(payload) {
if (done) return;
done = true;
clearTimeout(timer);
callback(payload);
}
try {
service.call(uri, params || {}, function (message) {
finish((message && message.payload) || {});
});
} catch (err) {
finish({ returnValue: false, errorText: err.message });
}
}
/** Value that turns a setting off, or null when it is off/not a toggle. */
function disabledValueFor(value) {
if (value === true) return false;
if (typeof value === 'string') {
var normalized = value.toLowerCase();
if (normalized === 'on') return 'off';
if (normalized === 'true') return 'false';
if (normalized === 'yes') return 'no';
if (normalized === 'enable' || normalized === 'enabled') return 'disable';
}
return null;
}
function discoverSettings(callback) {
var found = [];
var index = 0;
function next() {
if (index >= SETTINGS_CATEGORIES.length) {
callback(found);
return;
}
var category = SETTINGS_CATEGORIES[index];
index += 1;
callLuna('luna://com.webos.settingsservice/getSystemSettings', { category: category }, function (payload) {
var settings = (payload && payload.settings) || {};
Object.keys(settings).forEach(function (key) {
if (!SETTINGS_KEY_PATTERN.test(key)) return;
found.push({
category: category,
key: key,
value: settings[key],
canDisable: disabledValueFor(settings[key]) !== null
});
});
next();
});
}
next();
}
function disableSettings(log, callback) {
discoverSettings(function (found) {
var targets = found.filter(function (entry) {
return entry.canDisable;
});
if (!found.length) {
log.add('no update-related system settings exposed by this firmware');
callback();
return;
}
if (!targets.length) {
log.add('update-related settings are already off: ' + found.map(function (e) {
return e.key + '=' + JSON.stringify(e.value);
}).join(', '));
callback();
return;
}
var backup = readJson(SETTINGS_BACKUP_PATH, {});
targets.forEach(function (entry) {
var id = entry.category + '.' + entry.key;
if (!(id in backup)) backup[id] = entry.value;
});
try {
writeJson(SETTINGS_BACKUP_PATH, backup);
} catch (err) {
log.add('! could not save settings backup: ' + err.message);
}
var index = 0;
function next() {
if (index >= targets.length) {
callback();
return;
}
var entry = targets[index];
index += 1;
var settings = {};
settings[entry.key] = disabledValueFor(entry.value);
callLuna(
'luna://com.webos.settingsservice/setSystemSettings',
{ category: entry.category, settings: settings },
function (payload) {
if (payload && payload.returnValue) {
log.add('turned off ' + entry.category + '/' + entry.key +
' (' + JSON.stringify(entry.value) + ' -> ' + JSON.stringify(settings[entry.key]) + ')');
} else {
log.add('! could not change ' + entry.category + '/' + entry.key + ': ' +
((payload && payload.errorText) || 'unknown error'));
}
next();
}
);
}
next();
});
}
function restoreSettings(log, callback) {
var backup = readJson(SETTINGS_BACKUP_PATH, null);
if (!backup || !Object.keys(backup).length) {
log.add('no system settings to restore');
callback();
return;
}
var ids = Object.keys(backup);
var index = 0;
function next() {
if (index >= ids.length) {
try {
fs.unlinkSync(SETTINGS_BACKUP_PATH);
} catch (err) {
/* nothing to clean up */
}
callback();
return;
}
var id = ids[index];
index += 1;
var split = id.indexOf('.');
var category = id.slice(0, split);
var key = id.slice(split + 1);
var settings = {};
settings[key] = backup[id];
callLuna(
'luna://com.webos.settingsservice/setSystemSettings',
{ category: category, settings: settings },
function (payload) {
log.add((payload && payload.returnValue ? 'restored ' : '! could not restore ') +
category + '/' + key + ' = ' + JSON.stringify(backup[id]));
next();
}
);
}
next();
}
/* -------------------------------------------------------------- boot hook */
function bootHookScript(config, domains, jobs) {
return [
'#!/bin/sh',
'# LG Update Blocker boot hook - generated by ' + pkgInfo.name + ' v' + pkgInfo.version + '.',
'# Regenerated every time the settings are applied; delete it to disable.',
'',
'STATE=' + STATE_DIR,
'LOG=' + BOOT_LOG_PATH,
'HOSTS_TMP=' + HOSTS_TMP,
'BLOCK_HOSTS=' + (config.blockHosts ? 1 : 0),
'PURGE_CACHE=' + (config.purgeCache ? 1 : 0),
'LOCK_CACHE=' + (config.lockCache ? 1 : 0),
'STOP_SERVICES=' + (config.stopServices ? 1 : 0),
'CACHE_DIRS="' + CACHE_DIRS.join(' ') + '"',
'JOBS="' + jobs.join(' ') + '"',
'',
'mkdir -p "$STATE"',
'if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt 65536 ]; then rm -f "$LOG"; fi',
'exec >>"$LOG" 2>&1',
'echo "--- $(date) LG Update Blocker ---"',
'',
'if [ "$BLOCK_HOSTS" = 1 ] && [ -f "$STATE/hosts.txt" ]; then',
' if ! (: >> /etc/hosts) 2>/dev/null; then',
' cp /etc/hosts "$HOSTS_TMP" && chmod 644 "$HOSTS_TMP" \\',
' && mount --bind "$HOSTS_TMP" /etc/hosts \\',
' && echo "bind-mounted writable /etc/hosts"',
' fi',
' if grep -q "lg-update-blocker" /etc/hosts 2>/dev/null; then',
' echo "hosts entries already present"',
' elif (: >> /etc/hosts) 2>/dev/null; then',
' {',
' echo "' + MARKER_BEGIN + '"',
' sed -e "s/#.*//" -e "s/[[:space:]]*$//" -e "/^$/d" "$STATE/hosts.txt" | while read -r domain; do',
' echo "127.0.0.1 $domain"',
' echo "::1 $domain"',
' done',
' echo "' + MARKER_END + '"',
' } >> /etc/hosts && echo "hosts entries added"',
' else',
' echo "could not make /etc/hosts writable"',
' fi',
'fi',
'',
'if [ "$PURGE_CACHE" = 1 ]; then',
' for dir in $CACHE_DIRS; do',
' [ -d "$dir" ] || continue',
' case "$dir" in /mnt/lg/*) ;; *) continue ;; esac',
' entries=$(ls -A "$dir" 2>/dev/null)',
' [ -n "$entries" ] || continue',
' rm -rf "$dir"/* "$dir"/.[!.]* 2>/dev/null',
' echo "purged staged item(s) from $dir: $(echo "$entries" | tr \'\\n\' \' \')"',
' done',
'fi',
'',
'if [ "$LOCK_CACHE" = 1 ]; then',
' mkdir -p "$STATE/empty"',
' for dir in $CACHE_DIRS; do',
' [ -d "$dir" ] || continue',
' case "$dir" in /mnt/lg/*) ;; *) continue ;; esac',
' if awk \'{print $2}\' /proc/mounts | grep -qx "$dir"; then',
' echo "$dir already locked"',
' continue',
' fi',
' if mount -o bind,ro "$STATE/empty" "$dir"; then',
' mount -o bind,remount,ro "$STATE/empty" "$dir" 2>/dev/null',
' echo "locked $dir"',
' else',
' echo "could not lock $dir"',
' fi',
' done',
'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"',
''
].join('\n');
}
function installBootHook(config, jobs, log) {
var domains = readDomains();
mkdirp(STATE_DIR);
mkdirp(BOOT_HOOK_DIR);
fs.writeFileSync(HOSTS_LIST_PATH, domains.join('\n') + '\n');
fs.writeFileSync(BOOT_HOOK_PATH, bootHookScript(config, domains, jobs));
fs.chmodSync(BOOT_HOOK_PATH, parseInt('755', 8));
log.add('boot hook installed at ' + BOOT_HOOK_PATH);
}
function removeBootHook(log) {
if (!exists(BOOT_HOOK_PATH)) {
log.add('no boot hook installed');
return;
}
try {
fs.unlinkSync(BOOT_HOOK_PATH);
log.add('removed boot hook ' + BOOT_HOOK_PATH);
} catch (err) {
log.add('! could not remove boot hook: ' + err.message);
}
}
/* ---------------------------------------------------------------- methods */
function baseStatus() {
return {
version: pkgInfo.version,
uid: typeof process.getuid === 'function' ? process.getuid() : -1,
root: isRoot(),
config: readConfig(),
hosts: {
path: HOSTS_PATH,
writable: hostsWritable(),
bindMounted: isMountPoint(HOSTS_PATH),
blockedDomains: hostsBlockedDomains(),
totalDomains: readDomains().length
},
cache: cacheStatus(),
jobs: discoverJobs(),
bootHook: {
path: BOOT_HOOK_PATH,
installed: exists(BOOT_HOOK_PATH)
}
};
}
function respondError(message, text) {
message.respond({ returnValue: false, errorText: text });
}
service.register('status', function (message) {
var status = baseStatus();
discoverSettings(function (settings) {
status.settings = settings;
status.returnValue = true;
message.respond(status);
});
});
service.register('apply', function (message) {
if (!isRoot()) {
respondError(message, 'Service is not running as root - elevate it through the Homebrew Channel first.');
return;
}
var log = new Log();
var config = mergeConfig(readConfig(), message.payload || {});
try {
mkdirp(STATE_DIR);
writeJson(CONFIG_PATH, config);
if (config.blockHosts) {
applyHosts(log);
} else {
removeHosts(log);
}
if (config.purgeCache) purgeCache(log);
if (config.lockCache) {
lockCache(log);
} else {
unlockCache(log);
}
var jobs = discoverJobs();
if (config.stopServices) {
stopJobs(jobs, log);
}
if (anyEnabled(config)) {
installBootHook(config, config.stopServices ? jobs : [], log);
} else {
removeBootHook(log);
}
} catch (err) {
log.add('! ' + err.message);
message.respond({ returnValue: false, errorText: err.message, log: log.lines, config: config });
return;
}
function finish() {
log.add('done - reboot the TV to verify the popup is gone');
message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() });
}
if (config.disableSettings) {
disableSettings(log, finish);
} else {
restoreSettings(log, finish);
}
});
service.register('revert', function (message) {
if (!isRoot()) {
respondError(message, 'Service is not running as root - elevate it through the Homebrew Channel first.');
return;
}
var log = new Log();
var config = {};
CONFIG_KEYS.forEach(function (key) {
config[key] = false;
});
try {
removeHosts(log);
unlockCache(log);
removeBootHook(log);
mkdirp(STATE_DIR);
writeJson(CONFIG_PATH, config);
} catch (err) {
log.add('! ' + err.message);
}
restoreSettings(log, function () {
log.add('everything reverted; stopped services come back after a reboot');
message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() });
});
});
service.register('purge', function (message) {
if (!isRoot()) {
respondError(message, 'Service is not running as root - elevate it through the Homebrew Channel first.');
return;
}
var log = new Log();
purgeCache(log);
message.respond({ returnValue: true, log: log.lines, status: baseStatus() });
});
service.register('diagnostics', function (message) {
var status = baseStatus();
discoverSettings(function (settings) {
message.respond({
returnValue: true,
status: status,
settings: settings,
osInfo: readJson('/var/run/nyx/os_info.json', null),
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'),
mounts: mountTargets().filter(function (target) {
return target === HOSTS_PATH || target.indexOf('/mnt/lg') === 0;
})
});
});
});
console.info(pkgInfo.name + ' v' + pkgInfo.version + ' started, uid=' +
(typeof process.getuid === 'function' ? process.getuid() : 'unknown'));
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* Assembles build/app and build/service from the sources in this repository.
*
* There is no bundler and there are no runtime dependencies: the frontend talks
* to the Luna bus through PalmServiceBridge and the service uses the
* webos-service module provided by the TV itself. The only thing this script
* does is copy files and substitute the __APP_ID__ / __SERVICE_ID__ /
* __VERSION__ / __TITLE__ placeholders, so that the application id lives in
* exactly one place (the "name" field of package.json).
*/
'use strict';
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const vars = {
__APP_ID__: pkg.name,
__SERVICE_ID__: `${pkg.name}.service`,
__VERSION__: pkg.version,
__TITLE__: pkg.title,
};
const TEXT_EXTENSIONS = ['.js', '.json', '.html', '.css', '.txt', '.sh'];
function substitute(contents) {
return Object.keys(vars).reduce(
(acc, key) => acc.split(key).join(vars[key]),
contents,
);
}
function copy(src, dst) {
fs.mkdirSync(path.dirname(dst), { recursive: true });
if (TEXT_EXTENSIONS.includes(path.extname(src))) {
fs.writeFileSync(dst, substitute(fs.readFileSync(src, 'utf8')));
} else {
fs.copyFileSync(src, dst);
}
}
function copyTree(src, dst) {
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const from = path.join(src, entry.name);
const to = path.join(dst, entry.name);
if (entry.isDirectory()) {
copyTree(from, to);
} else {
copy(from, to);
}
}
}
const build = path.join(root, 'build');
fs.rmSync(build, { recursive: true, force: true });
// Application (frontend)
const appDir = path.join(build, 'app');
copyTree(path.join(root, 'app'), appDir);
copy(path.join(root, 'appinfo.json'), path.join(appDir, 'appinfo.json'));
copy(path.join(root, 'assets', 'icon.png'), path.join(appDir, 'icon.png'));
copy(path.join(root, 'assets', 'largeIcon.png'), path.join(appDir, 'largeIcon.png'));
// JS service
const serviceDir = path.join(build, 'service');
copyTree(path.join(root, 'service'), serviceDir);
fs.writeFileSync(
path.join(serviceDir, 'services.json'),
`${JSON.stringify(
{
id: vars.__SERVICE_ID__,
description: `${pkg.title} service`,
services: [{ name: vars.__SERVICE_ID__ }],
},
null,
2,
)}\n`,
);
console.log(`Built ${vars.__APP_ID__} ${vars.__VERSION__} into ${build}`);
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* Generates the two files the Homebrew Channel needs:
*
* dist/<id>.manifest.json - package manifest (what "Install" reads)
* dist/apps.json - a one-package repository index, so the release
* can be added under Homebrew Channel ->
* Settings -> Add repository
*
* Both are uploaded as release assets, so the stable repository URL is
* https://github.com/<owner>/<repo>/releases/latest/download/apps.json
* and the manifest/ipk URLs inside point at the exact tagged release.
*
* Usage: node tools/gen-manifest.js [--repo owner/repo] [--tag v1.0.0]
* On GitHub Actions both are picked up from the environment.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const root = path.resolve(__dirname, '..');
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
function arg(name, fallback) {
const index = process.argv.indexOf(name);
return index !== -1 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
}
const repository = arg('--repo', process.env.GITHUB_REPOSITORY || '');
const tag = arg(
'--tag',
process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : `v${pkg.version}`,
);
const ipkName = `${pkg.name}_${pkg.version}_all.ipk`;
const ipkPath = path.join(root, 'dist', ipkName);
if (!fs.existsSync(ipkPath)) {
console.error(`${ipkPath} not found - run "npm run build && npm run package" first.`);
process.exit(1);
}
const ipk = fs.readFileSync(ipkPath);
const releaseBase = repository
? `https://github.com/${repository}/releases/download/${tag}`
: null;
if (!releaseBase) {
console.warn(
'No repository known (pass --repo owner/repo or set GITHUB_REPOSITORY); ' +
'generating relative URLs, which only work when every file sits next to the manifest.',
);
}
const iconUri = `data:image/png;base64,${fs
.readFileSync(path.join(root, 'assets', 'icon.png'))
.toString('base64')}`;
const manifestName = `${pkg.name}.manifest.json`;
const manifest = {
id: pkg.name,
version: pkg.version,
type: 'web',
title: pkg.title,
appDescription: pkg.description,
iconUri,
sourceUrl: repository ? `https://github.com/${repository}` : undefined,
rootRequired: true,
ipkUrl: releaseBase ? `${releaseBase}/${ipkName}` : ipkName,
ipkHash: { sha256: crypto.createHash('sha256').update(ipk).digest('hex') },
ipkSize: ipk.length,
};
const appsJson = {
paging: { page: 1, count: 1, maxPage: 1, itemsTotal: 1, prevUrl: null, nextUrl: null },
packages: [
{
id: manifest.id,
title: manifest.title,
iconUri,
manifestUrl: releaseBase ? `${releaseBase}/${manifestName}` : manifestName,
manifest,
pool: 'main',
shortDescription: pkg.description,
fullDescriptionUrl: releaseBase ? `${releaseBase}/description.html` : 'description.html',
},
],
};
fs.writeFileSync(path.join(root, 'dist', manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
fs.writeFileSync(path.join(root, 'dist', 'apps.json'), `${JSON.stringify(appsJson, null, 2)}\n`);
fs.copyFileSync(path.join(root, 'docs', 'description.html'), path.join(root, 'dist', 'description.html'));
console.log(`ipk : dist/${ipkName} (${(ipk.length / 1024).toFixed(1)} kB)`);
console.log(`sha256 : ${manifest.ipkHash.sha256}`);
console.log(`manifest : dist/${manifestName}`);
console.log(`repository: dist/apps.json`);
if (repository) {
console.log(
`\nAdd this in Homebrew Channel -> Settings -> Add repository:\n` +
` https://github.com/${repository}/releases/latest/download/apps.json`,
);
}