Build / build (push) Successful in 46s
Blocking snu.lge.com in /etc/hosts never stopped the boot popup on a CX, and the reason only showed up on the TV itself: /usr/sbin/update is a systemd unit (webos-mbd.target) that runs its version check against https://snu.lge.com/CheckSWAutoUpdate.laf 15-20 seconds before the Homebrew Channel gets as far as running its init.d hooks. The check therefore succeeds on every boot and the alert is already on screen before any hosts entry exists. Homebrew Channel's own "block system updates" toggle loses the same race. Nothing running that late can win it: every systemd unit path is a read-only overlay except tmpfs /run, and no persistent setting gates the check - automaticUpdate, support/softwareUpdateEnable, hotelMode/swUpdateEnable and .UpdateIsInprogress were each measured by restarting the daemon and counting its requests. So dismiss the popup instead. The boot hook recovers the alert id from the updater's own log (_gAlertWindowId), which is the only way to reach an alert that opened before we could subscribe - com.webos.notification never reports it to a late subscriber and closeAllAlerts rejects every source id it accepts. A companion alert-watch.sh then stays subscribed for the rest of the session. Also drop two things that were never true. There is no staged firmware image driving the popup (the staging dir is empty at boot; the size the daemon reports is in-memory only), and there is no update service to stop - /etc/init is dead upstart leftovers on a systemd TV, so the old stopServices layer printed "stopped update" while doing nothing. - rename the hook to 00-lgupdateblocker so run-parts runs it first, removing the legacy file on apply - add support/ and hotelMode/ to the scanned settings categories - stop matching "ota" inside screenRotation, which would have switched screen rotation off - kill the watcher by process group, and make its TERM trap exit - a trap that only returns resumes the script, which then re-subscribes Verified on an LG OLED55CX8LB (webOS 5, 04.60.65): after a reboot the boot log records "dismissed update popup com.webos.service.update-1788650782451", matching the id the updater logged that boot. Raised 01:26:22, closed 01:26:37 - so it is visible for ~15s and then goes away on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
/**
|
|
* 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: 'dismissPopup',
|
|
title: 'Close the update popup',
|
|
desc: 'The TV asks LG about updates ~20s before homebrew starts, so the popup is closed instead'
|
|
},
|
|
{
|
|
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'
|
|
}
|
|
];
|
|
|
|
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,
|
|
dismissPopup: true,
|
|
purgeCache: true,
|
|
lockCache: false,
|
|
disableSettings: true
|
|
};
|
|
|
|
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 : '-');
|
|
var popup = status.popup || {};
|
|
row('Update popup this boot', popup.lastAlertId ? 'raised, then closed' : 'none raised');
|
|
row('Popup watcher', popup.watcherPid ? 'running (pid ' + popup.watcherPid + ')' : 'not running');
|
|
row('Boot hook', status.bootHook.installed ? 'installed' : 'not installed');
|
|
|
|
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();
|
|
}
|
|
});
|
|
})();
|