Build / build (push) Successful in 48s
Closing the popup after the fact left it on screen for the 15-20 seconds between the updater's version check and the Homebrew Channel running its init.d hooks. That race cannot be won, so remove it. /usr/sbin/update is launched on demand by ls-hubd via a manifest, and ls-hubd.conf lists ManifestsVolatileDirectories under /var - writable, persistent, and how webOS itself ships manifest updates. A manifest there with the same id and a higher version replaces the read-only one, so drop in a copy that keeps every role and permission file and only empties serviceFiles. Nothing on the bus can start the updater after that: no version check, no alert, nothing to dismiss. Callers get an immediate "Service does not exist" rather than a hang, and deleting the file undoes it. Verified on a CX (webOS 5, 04.60.65) across reboots: no update process, /tmp/var/log/update.log never created, no alert. Previously that log was 55 kB with two server checks and an _gAlertWindowId per boot. Revert puts the updater back on the bus. The manifest, its D-Bus service file and the updater binary are all discovered from ls-hubd.conf rather than hardcoded. The popup-closing layer stays as a fallback for when this one is off. Also tried and rejected, now documented in the README: update-related settings, masking update.service (no writable unit directory), and breaking its ping through /var/systemd/system/env/update.env - the ping does fail, but something else on the bus activates the updater anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
423 lines
13 KiB
JavaScript
423 lines
13 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: 'blockUpdater',
|
|
title: 'Stop the updater running',
|
|
desc: 'Takes /usr/sbin/update off the bus, so no version check happens and no popup appears'
|
|
},
|
|
{
|
|
key: 'blockHosts',
|
|
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: 'Fallback: closes a popup that appears anyway, e.g. with the updater left running'
|
|
},
|
|
{
|
|
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 = {
|
|
blockUpdater: true,
|
|
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)');
|
|
var updater = status.updater || {};
|
|
if (!updater.found) {
|
|
row('Update service', 'not present on this TV');
|
|
} else {
|
|
row('Update service', (updater.blocked ? 'disabled' : 'launchable') +
|
|
(updater.running ? ', running now' : ', not running'));
|
|
}
|
|
row('Hosts entries', status.hosts.blockedDomains + ' / ' + status.hosts.totalDomains +
|
|
(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();
|
|
}
|
|
});
|
|
})();
|