@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
*.ipk
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -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 |
@@ -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 |
Generated
+6295
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "__SERVICE_ID__",
|
||||
"version": "__VERSION__",
|
||||
"description": "__TITLE__ service",
|
||||
"main": "service.js"
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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}`);
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user