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