Merge Luma launcher fork into monorepo
Move trampoline files (boot-hook, watcher, overlay) into trampoline/ subdirectory. Add upstream Luma v2.3.1 source at repo root with our additions: - Page::Ha (4th nav tab) with placeholder empty state - HA tab config schema and default entity mapping (ha-home/) - Nav keys renamed: "home" → "start", new "ha" tab between Apps and Settings - All state.rs/view.rs/main.rs/tests.rs updated for 4-tab navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc3ca0f3b9
commit
cbea5a322f
204 files changed
+25056
-65
No files matched your search
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# Luma's optional cold-boot hook. Installed only when explicitly requested.
|
||||
APP_DIR=/media/developer/apps/usr/palm/applications/com.ecklf.luma
|
||||
HOOK=/var/lib/webosbrew/init.d/65-luma
|
||||
case "${1:-}" in
|
||||
enable)
|
||||
[ "$(id -u)" = 0 ] || { printf '%s\n' 'Run as root.' >&2; exit 1; }
|
||||
[ -f "$APP_DIR/appinfo.json" ] || { printf '%s\n' 'Install Luma first.' >&2; exit 1; }
|
||||
mkdir -p /var/lib/webosbrew/init.d
|
||||
if [ -e "$HOOK" ] || [ -L "$HOOK" ]; then
|
||||
[ "$(readlink "$HOOK")" = "$APP_DIR/autostart.sh" ] || { printf '%s\n' '65-luma is already used by another file.' >&2; exit 1; }
|
||||
fi
|
||||
chmod 755 "$APP_DIR/autostart.sh"
|
||||
ln -sf "$APP_DIR/autostart.sh" "$HOOK"
|
||||
printf '%s\n' 'Luma will open after a full boot.'
|
||||
exit 0 ;;
|
||||
disable)
|
||||
[ "$(id -u)" = 0 ] || exit 1
|
||||
if [ "$(readlink "$HOOK")" = "$APP_DIR/autostart.sh" ]; then rm -f "$HOOK"; fi
|
||||
printf '%s\n' 'Luma boot launch disabled.'
|
||||
exit 0 ;;
|
||||
esac
|
||||
# A lock lives for this boot only; do not launch twice when the hook is invoked again.
|
||||
(
|
||||
exec 9>/tmp/luma-boot.lock
|
||||
flock -n 9 || exit 0
|
||||
[ -f /tmp/luma-boot-done ] && exit 0
|
||||
[ -f "$APP_DIR/appinfo.json" ] || exit 0
|
||||
sleep 25
|
||||
# -n prints the reply. -t is timing mode, not a firmware-specific flag.
|
||||
RESULT="$(/usr/bin/luna-send -n 1 -w 5000 luna://com.webos.service.applicationmanager/launch '{"id":"com.ecklf.luma"}' 2>/dev/null)"
|
||||
printf '%s\n' "$RESULT"
|
||||
case "$RESULT" in *'"returnValue":true'*|*'"returnValue": true'*) touch /tmp/luma-boot-done ;; esac
|
||||
) </dev/null >/tmp/luma-boot.log 2>&1 &
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
// Root-only TV integration. Keep ES5 syntax for the TV's bundled Node runtime.
|
||||
// The native executable remains in its installed package so its LS2 role,
|
||||
// libraries, assets and preference identity continue to use that installation.
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var execFileSync = require('child_process').execFileSync;
|
||||
var spawnSync = require('child_process').spawnSync;
|
||||
var APP = 'com.ecklf.luma';
|
||||
var HOME = 'com.webos.app.home';
|
||||
var STATE = '/var/lib/luma-home';
|
||||
var STAGE = STATE + '/app';
|
||||
var TARGET = '/usr/palm/applications/' + HOME;
|
||||
var HOOK = '/var/lib/webosbrew/init.d/64-luma-home';
|
||||
var CONFIG = STATE + '/state.json';
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
function json(file) {
|
||||
return JSON.parse(read(file));
|
||||
}
|
||||
|
||||
function exists(file) {
|
||||
try {
|
||||
fs.lstatSync(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.writeFileSync(file + '.new', JSON.stringify(value, null, 2) + '\n', { mode: 420 });
|
||||
fs.renameSync(file + '.new', file);
|
||||
}
|
||||
|
||||
function run(file, args) {
|
||||
return String(execFileSync(file, args, { timeout: 8000, encoding: 'utf8' }));
|
||||
}
|
||||
|
||||
function pause() {
|
||||
run('/bin/sleep', ['1']);
|
||||
}
|
||||
|
||||
function excerpt(output) {
|
||||
return (
|
||||
String(output || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 480) || '<empty>'
|
||||
);
|
||||
}
|
||||
|
||||
function luna(method, payload) {
|
||||
// -n prints one JSON reply to stdout. -t is timing mode and logs the
|
||||
// payload to stderr instead, leaving timing summaries on stdout.
|
||||
var result = spawnSync(
|
||||
'/usr/bin/luna-send',
|
||||
[
|
||||
'-n',
|
||||
'1',
|
||||
'-w',
|
||||
'4000',
|
||||
'luna://com.webos.applicationManager/' + method,
|
||||
JSON.stringify(payload)
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 8000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
var stdout = String(result.stdout || '').trim();
|
||||
var diagnostics = '; stdout: ' + excerpt(stdout) + '; stderr: ' + excerpt(result.stderr);
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
'Luna ' +
|
||||
method +
|
||||
' failed (' +
|
||||
(result.error
|
||||
? result.error.message
|
||||
: 'exit ' + result.status + ', signal ' + result.signal) +
|
||||
')' +
|
||||
diagnostics
|
||||
);
|
||||
}
|
||||
if (!stdout) throw new Error('No Luna JSON reply from ' + method + diagnostics);
|
||||
var reply;
|
||||
try {
|
||||
reply = JSON.parse(stdout);
|
||||
} catch (error) {
|
||||
throw new Error('Invalid Luna JSON reply from ' + method + ': ' + error.message + diagnostics);
|
||||
}
|
||||
if (
|
||||
!reply ||
|
||||
typeof reply !== 'object' ||
|
||||
Array.isArray(reply) ||
|
||||
typeof reply.returnValue !== 'boolean'
|
||||
) {
|
||||
throw new Error('Invalid Luna response from ' + method + diagnostics);
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
function accepted(reply, action) {
|
||||
if (!reply || reply.returnValue !== true) {
|
||||
throw new Error(action + ': ' + JSON.stringify(reply));
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
function mkdir(directory) {
|
||||
if (!fs.existsSync(directory)) fs.mkdirSync(directory, 493);
|
||||
}
|
||||
|
||||
function mounted() {
|
||||
if (!fs.existsSync(STAGE)) return false;
|
||||
var a = fs.statSync(STAGE);
|
||||
var b = fs.statSync(TARGET);
|
||||
return a.dev === b.dev && a.ino === b.ino;
|
||||
}
|
||||
|
||||
function foreignMount() {
|
||||
return (
|
||||
read('/proc/self/mountinfo')
|
||||
.split('\n')
|
||||
.some(function (line) {
|
||||
var target = line.split(' ')[4] || '';
|
||||
return target === TARGET || target.indexOf(TARGET + '/') === 0;
|
||||
}) && !mounted()
|
||||
);
|
||||
}
|
||||
|
||||
function releaseHome() {
|
||||
var result;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
try {
|
||||
result = luna('releaseBlockSystemApp', { id: HOME });
|
||||
if (result.returnValue === true || /not.*blocked/i.test(result.errorText || '')) return;
|
||||
} catch (error) {
|
||||
result = { errorText: error.message };
|
||||
}
|
||||
pause();
|
||||
}
|
||||
throw new Error('Could not release Home: ' + JSON.stringify(result));
|
||||
}
|
||||
|
||||
function reread(expected) {
|
||||
var payload = { id: HOME };
|
||||
var schema = '/etc/palm/schemas/sam/applicationManager.blockSystemApp.schema';
|
||||
if (fs.existsSync(schema) && read(schema).indexOf('"forceBlockSystemApp"') !== -1) {
|
||||
payload.forceBlockSystemApp = false;
|
||||
}
|
||||
// Always release, even if the block request times out after taking effect.
|
||||
try {
|
||||
var reply = luna('blockSystemApp', payload);
|
||||
if (!/already\s*blocked/i.test(reply.errorText || '')) {
|
||||
accepted(reply, 'Block Home for manifest refresh');
|
||||
}
|
||||
} finally {
|
||||
releaseHome();
|
||||
}
|
||||
for (var i = 0; i < 15; i++) {
|
||||
var info = accepted(luna('getAppInfo', { id: HOME }), 'Read Home manifest').appInfo;
|
||||
if (
|
||||
info &&
|
||||
info.type === expected.type &&
|
||||
(typeof expected.main !== 'string' ||
|
||||
path.basename(info.main || '') === path.basename(expected.main)) &&
|
||||
(expected.main !== 'luma' ||
|
||||
(Array.isArray(info.appCategory) && info.appCategory.indexOf('home') !== -1))
|
||||
)
|
||||
return;
|
||||
pause();
|
||||
}
|
||||
throw new Error('SAM did not read the expected Home manifest');
|
||||
}
|
||||
|
||||
function foreground() {
|
||||
return accepted(luna('getForegroundAppInfo', {}), 'Read foreground app').appId;
|
||||
}
|
||||
|
||||
function normalRunning() {
|
||||
return (accepted(luna('running', {}), 'Read running apps').running || []).some(function (entry) {
|
||||
return entry.id === APP;
|
||||
});
|
||||
}
|
||||
|
||||
function closeNormal() {
|
||||
if (!normalRunning()) return;
|
||||
try {
|
||||
// On TV, close's id identifies a process. Use the app-ID endpoint for APP.
|
||||
accepted(luna('closeByAppId', { id: APP }), 'Close the normal Luma instance');
|
||||
} catch (error) {
|
||||
// The app may have exited between the running query and the close request.
|
||||
if (normalRunning()) throw error;
|
||||
return;
|
||||
}
|
||||
for (var attempt = 0; attempt <= 5; attempt++) {
|
||||
if (!normalRunning()) return;
|
||||
if (attempt < 5) pause();
|
||||
}
|
||||
throw new Error('Close Luma before enabling Home integration');
|
||||
}
|
||||
|
||||
function stopHome(appDir) {
|
||||
var stopped = [];
|
||||
fs.readdirSync('/proc').forEach(function (pid) {
|
||||
if (!/^\d+$/.test(pid)) return;
|
||||
try {
|
||||
var args = read('/proc/' + pid + '/cmdline').split('\0');
|
||||
var oldNative = args[0] === '/usr/bin/' + HOME;
|
||||
var flutter =
|
||||
path.basename(args[0]) === 'flutter-client' &&
|
||||
args.indexOf('-i') !== -1 &&
|
||||
args[args.indexOf('-i') + 1] === HOME;
|
||||
var luma =
|
||||
fs.readlinkSync('/proc/' + pid + '/exe').replace(/ \(deleted\)$/, '') === appDir + '/luma';
|
||||
var metadata = null;
|
||||
try {
|
||||
metadata = JSON.parse(args[1]);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error;
|
||||
}
|
||||
if (oldNative || flutter || (luma && metadata && metadata.nid === HOME)) {
|
||||
process.kill(Number(pid), 'SIGTERM');
|
||||
stopped.push(pid);
|
||||
}
|
||||
} catch (error) {
|
||||
// A process may exit while /proc is being read. Kernel threads have no exe.
|
||||
if (error.code !== 'ENOENT' && error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
});
|
||||
for (var i = 0; i < 5; i++) {
|
||||
if (
|
||||
!stopped.some(function (pid) {
|
||||
return fs.existsSync('/proc/' + pid);
|
||||
})
|
||||
)
|
||||
return;
|
||||
pause();
|
||||
}
|
||||
throw new Error('The previous Home process has not stopped');
|
||||
}
|
||||
|
||||
function launchHome() {
|
||||
accepted(luna('launch', { id: HOME }), 'Open Home');
|
||||
}
|
||||
|
||||
function hookOwned() {
|
||||
try {
|
||||
return fs.readlinkSync(HOOK) === STATE + '/home.sh';
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT' || error.code === 'EINVAL') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function restore(config) {
|
||||
var wasHome = false;
|
||||
try {
|
||||
wasHome = foreground() === HOME;
|
||||
} catch (error) {
|
||||
console.error('Luma Home: ' + error.message + ', restoring the files first');
|
||||
}
|
||||
if (foreignMount()) throw new Error('Another integration is mounted over LG Home');
|
||||
if (mounted()) {
|
||||
try {
|
||||
run('/bin/umount', [TARGET]);
|
||||
} catch (error) {
|
||||
// A running Home may still hold its working directory in the mount.
|
||||
console.error('Luma Home: ' + error.message + ', detaching the mount');
|
||||
run('/bin/umount', ['-l', TARGET]);
|
||||
}
|
||||
if (mounted()) throw new Error('The Luma Home mount is still active');
|
||||
}
|
||||
// Also finish a restore interrupted between unmounting and the rescan.
|
||||
// Read the uncovered files, since a firmware update may have changed LG Home.
|
||||
reread(json(TARGET + '/appinfo.json'));
|
||||
stopHome(config.appDir);
|
||||
if (wasHome) launchHome();
|
||||
}
|
||||
|
||||
// The LG splash is a static file. Stage the splash of the owner's current background, so a cold
|
||||
// boot into Luma Home opens on their wallpaper. Falls back to the package default on any error.
|
||||
function stageSplash(appDir) {
|
||||
var background = 'dunes';
|
||||
var candidates = [
|
||||
'/media/developer/' + APP + '/preferences.json',
|
||||
'/media/developer/temp/' + APP + '/preferences.json',
|
||||
'/media/internal/.' + APP + '/preferences.json'
|
||||
];
|
||||
// Match storage.rs: production native jails prefer internal storage. Reads
|
||||
// skip absent files, preserving an existing destination before migration.
|
||||
if (appDir.indexOf('/media/cryptofs/') === 0) candidates.unshift(candidates.pop());
|
||||
candidates.push(appDir + '/preferences.json');
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
try {
|
||||
var saved = json(candidates[i]).background;
|
||||
if (typeof saved === 'string' && /^[a-z0-9-]{1,32}$/.test(saved)) {
|
||||
background = saved;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') console.log('home: preferences unreadable: ' + error.message);
|
||||
}
|
||||
}
|
||||
var source = appDir + '/splash/' + background + '.png';
|
||||
if (!fs.existsSync(source)) source = appDir + '/splash.png';
|
||||
try {
|
||||
fs.writeFileSync(STAGE + '/splash.png.new', fs.readFileSync(source), { mode: 420 });
|
||||
fs.renameSync(STAGE + '/splash.png.new', STAGE + '/splash.png');
|
||||
} catch (error) {
|
||||
console.log('home: splash not staged: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function apply(config, open) {
|
||||
if (foreignMount()) throw new Error('Another integration is mounted over LG Home');
|
||||
if (!fs.existsSync(config.appDir + '/luma')) throw new Error('Luma is no longer installed');
|
||||
stageSplash(config.appDir);
|
||||
var front = foreground();
|
||||
try {
|
||||
if (!mounted()) run('/bin/mount', ['--bind', STAGE, TARGET]);
|
||||
if (!mounted()) throw new Error('The Home bind mount did not take');
|
||||
reread(json(STAGE + '/appinfo.json'));
|
||||
stopHome(config.appDir);
|
||||
// Only replace the screen if Home is still in front. During boot the
|
||||
// owner may have already selected an input or another app.
|
||||
if (open || (front === HOME && foreground() === HOME)) launchHome();
|
||||
} catch (error) {
|
||||
try {
|
||||
restore(config);
|
||||
} catch (recovery) {
|
||||
throw new Error(error.message + '\nRestore also failed: ' + recovery.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function install(appDir) {
|
||||
appDir = fs.realpathSync(appDir);
|
||||
var app = json(appDir + '/appinfo.json');
|
||||
if (app.id !== APP || app.type !== 'native' || app.main !== 'luma') {
|
||||
throw new Error('Enable this from an installed native Luma package');
|
||||
}
|
||||
var release = read('/etc/starfish-release');
|
||||
if (!/release\s+(10|11)\./.test(release)) {
|
||||
throw new Error('This Home integration currently targets webOS 25/26 (10.x/11.x)');
|
||||
}
|
||||
if (foreignMount()) throw new Error('Another integration is mounted over LG Home');
|
||||
if (exists(HOOK) && !hookOwned()) throw new Error('64-luma-home is already in use');
|
||||
if (exists('/var/lib/webosbrew/init.d/65-luma')) {
|
||||
throw new Error('Disable the standalone cold-boot hook with autostart.sh disable first');
|
||||
}
|
||||
var previous = fs.existsSync(CONFIG) ? json(CONFIG) : null;
|
||||
var stock = mounted() ? previous && previous.stock : json(TARGET + '/appinfo.json');
|
||||
if (!stock || stock.id !== HOME || stock.main === 'luma') {
|
||||
throw new Error('Could not identify the original LG Home manifest');
|
||||
}
|
||||
// Finish any normal tile instance before the Home identity starts using the
|
||||
// same settings. A later tile launch redirects in the native executable.
|
||||
closeNormal();
|
||||
mkdir(STAGE);
|
||||
// Symlinks keep the executable at its installed path for the dynamic loader
|
||||
// and LS2. They also follow ordinary in-place package updates.
|
||||
['luma', 'assets', 'lib', 'splash', app.icon, app.largeIcon].forEach(function (name) {
|
||||
var link = STAGE + '/' + name;
|
||||
if (exists(link + '.new')) fs.unlinkSync(link + '.new');
|
||||
fs.symlinkSync(appDir + '/' + name, link + '.new');
|
||||
fs.renameSync(link + '.new', link);
|
||||
});
|
||||
app.id = HOME;
|
||||
app.title = 'Home';
|
||||
app.visible = false;
|
||||
app.appCategory = ['home'];
|
||||
writeJson(STAGE + '/appinfo.json', app);
|
||||
fs.writeFileSync(STAGE + '/.luma-home', APP + '\n', { mode: 420 });
|
||||
['home.sh', 'home.cjs'].forEach(function (name) {
|
||||
var target = STATE + '/' + name;
|
||||
var data = fs.readFileSync(path.join(__dirname, name));
|
||||
fs.writeFileSync(target + '.new', data, { mode: name === 'home.sh' ? 493 : 420 });
|
||||
fs.renameSync(target + '.new', target);
|
||||
});
|
||||
var config = { enabled: false, appDir: appDir, stock: stock };
|
||||
writeJson(CONFIG, config);
|
||||
mkdir('/var/lib/webosbrew');
|
||||
mkdir('/var/lib/webosbrew/init.d');
|
||||
if (!hookOwned()) fs.symlinkSync(STATE + '/home.sh', HOOK);
|
||||
apply(config, true);
|
||||
config.enabled = true;
|
||||
writeJson(CONFIG, config);
|
||||
console.log('Luma is now Home. Home Auto Launch and Quick Start+ follow the TV settings.');
|
||||
}
|
||||
|
||||
try {
|
||||
var command = process.argv[2];
|
||||
if (command === 'enable') {
|
||||
install(process.argv[3]);
|
||||
} else if (command === 'status') {
|
||||
var saved = fs.existsSync(CONFIG) ? json(CONFIG) : null;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
enabled: !!(saved && saved.enabled),
|
||||
mounted: mounted(),
|
||||
bootHook: hookOwned(),
|
||||
appDir: saved ? saved.appDir : null
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} else if (command === 'apply' || command === 'disable' || command === 'restore') {
|
||||
if (!fs.existsSync(CONFIG)) throw new Error('Luma Home has not been enabled');
|
||||
var config = json(CONFIG);
|
||||
if (command === 'apply' && config.enabled) {
|
||||
apply(config, false);
|
||||
} else {
|
||||
config.enabled = false;
|
||||
writeJson(CONFIG, config);
|
||||
restore(config);
|
||||
if (hookOwned()) fs.unlinkSync(HOOK);
|
||||
console.log('LG Home restored. Luma Home boot integration disabled.');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unknown Home integration command');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Luma Home: ' + error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
# Optional native Home replacement. The webosbrew hook has no .sh suffix.
|
||||
STATE=/var/lib/luma-home
|
||||
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) || exit 1
|
||||
COMMAND=${1:-boot}
|
||||
case "$COMMAND" in
|
||||
enable|disable|restore|status|apply|boot) ;;
|
||||
*) printf '%s\n' 'Usage: home.sh enable [APP_DIR] | disable | restore | status' >&2; exit 1 ;;
|
||||
esac
|
||||
[ "$(id -u)" = 0 ] || { printf '%s\n' 'Run as root.' >&2; exit 1; }
|
||||
[ -x /usr/bin/node ] || { printf '%s\n' 'The TV Node.js runtime is missing.' >&2; exit 1; }
|
||||
if [ "$COMMAND" = enable ]; then
|
||||
printf '%s\n' \
|
||||
'Enabling Luma Home will:' \
|
||||
' 1. Close any running normal Luma instance.' \
|
||||
" 2. Create $STATE with integration scripts, saved state and a staged native Home app linked to Luma." \
|
||||
" 3. Bind-mount $STATE/app over /usr/palm/applications/com.webos.app.home." \
|
||||
' 4. Temporarily block and release Home so SAM rereads its manifest.' \
|
||||
' 5. Stop the previous Home process and launch Luma as Home.' \
|
||||
' 6. Install /var/lib/webosbrew/init.d/64-luma-home to reapply the mount at cold boot.' \
|
||||
'' \
|
||||
"LG Home's original files remain underneath the mount." \
|
||||
'Home Auto Launch and Quick Start+ retain their current settings.' \
|
||||
"Restore LG Home with: sh $STATE/home.sh disable" \
|
||||
''
|
||||
while :; do
|
||||
printf '%s' 'Apply these changes? [y/n]: '
|
||||
if ! IFS= read -r answer; then
|
||||
printf '\n%s\n' 'Cancelled. No Home integration changes applied.'
|
||||
exit 1
|
||||
fi
|
||||
case "$answer" in
|
||||
y|Y) break ;;
|
||||
n|N) printf '%s\n' 'Cancelled. No Home integration changes applied.'; exit 0 ;;
|
||||
*) printf '%s\n' 'Please answer y or n.' ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
[ ! -L "$STATE" ] || exit 1
|
||||
if [ ! -d "$STATE" ]; then mkdir -m 755 "$STATE" || exit 1; fi
|
||||
[ "$(stat -c %u "$STATE")" = 0 ] || exit 1
|
||||
if [ "$COMMAND" = boot ]; then
|
||||
(
|
||||
sleep 5
|
||||
exec sh "$STATE/home.sh" apply
|
||||
) </dev/null >>"$STATE/boot.log" 2>&1 &
|
||||
exit 0
|
||||
fi
|
||||
exec 9>"$STATE/lock"
|
||||
flock -n 9 || { printf '%s\n' 'Luma Home integration is already being changed.' >&2; exit 1; }
|
||||
exec /usr/bin/node "$SCRIPT_DIR/home.cjs" "$COMMAND" "${2:-$SCRIPT_DIR}"
|
||||
@@ -0,0 +1,100 @@
|
||||
// Embedded in the native executable and run only through Homebrew's elevated
|
||||
// exec service when jailed file reads fail. Compatible with webOS's Node 8.
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var source = JSON.parse(process.argv[1]);
|
||||
var roots = [
|
||||
'/media/developer/apps/usr/palm/applications',
|
||||
'/media/cryptofs/apps/usr/palm/applications',
|
||||
'/usr/palm/applications'
|
||||
];
|
||||
var remaining = 512 * 1024;
|
||||
var icons = [];
|
||||
var seen = Object.create(null);
|
||||
|
||||
function read(file, limit) {
|
||||
var fd;
|
||||
try {
|
||||
fd = fs.openSync(file, 'r');
|
||||
var stat = fs.fstatSync(fd);
|
||||
if (!stat.isFile() || stat.size > limit) return null;
|
||||
var bytes = Buffer.alloc(stat.size);
|
||||
var offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
var count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
|
||||
if (!count) return null;
|
||||
offset += count;
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'EACCES') console.error(error.message);
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof source.id !== 'string' ||
|
||||
source.id.length > 128 ||
|
||||
!/^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*$/.test(source.id)
|
||||
) {
|
||||
throw new Error('Invalid application ID');
|
||||
}
|
||||
var directories = roots.map(function (root) {
|
||||
return path.join(root, source.id);
|
||||
});
|
||||
if (
|
||||
typeof source.directory === 'string' &&
|
||||
roots.indexOf(path.dirname(source.directory)) >= 0 &&
|
||||
source.directory.split('/').indexOf('..') < 0
|
||||
) {
|
||||
if (directories.indexOf(source.directory) >= 0)
|
||||
directories.splice(directories.indexOf(source.directory), 1);
|
||||
directories.unshift(source.directory);
|
||||
}
|
||||
directories.forEach(function (directory) {
|
||||
var manifest = {};
|
||||
var bytes = read(path.join(directory, 'appinfo.json'), 64 * 1024);
|
||||
if (bytes) {
|
||||
try {
|
||||
manifest = JSON.parse(bytes.toString('utf8'));
|
||||
if (!manifest || manifest.id !== source.id) manifest = {};
|
||||
} catch (error) {
|
||||
manifest = {};
|
||||
console.error('Invalid app manifest: ' + error.message);
|
||||
}
|
||||
}
|
||||
[source.large_icon, manifest.largeIcon, source.icon, manifest.icon, 'icon.png'].forEach(
|
||||
function (icon) {
|
||||
if (
|
||||
typeof icon !== 'string' ||
|
||||
!icon ||
|
||||
icon.length > 4096 ||
|
||||
icon.split('').some(function (character) {
|
||||
var code = character.charCodeAt(0);
|
||||
return code < 32 || code === 127;
|
||||
})
|
||||
)
|
||||
return;
|
||||
icon = icon.replace(/^file:\/\//, '');
|
||||
if (icon.indexOf('://') >= 0) return;
|
||||
if (icon.split('/').indexOf('..') >= 0) return;
|
||||
var file = path.resolve(directory, icon);
|
||||
if (['.png', '.jpg', '.jpeg', '.webp'].indexOf(path.extname(file).toLowerCase()) < 0) return;
|
||||
if (
|
||||
!roots.some(function (root) {
|
||||
return file.indexOf(root + '/') === 0;
|
||||
}) ||
|
||||
seen[file]
|
||||
)
|
||||
return;
|
||||
seen[file] = true;
|
||||
var data = read(file, remaining);
|
||||
if (!data || !data.length) return;
|
||||
remaining -= data.length;
|
||||
icons.push(data.toString('hex'));
|
||||
}
|
||||
);
|
||||
});
|
||||
console.log(JSON.stringify({ returnValue: true, icons: icons }));
|
||||
@@ -0,0 +1,57 @@
|
||||
// Embedded in the native executable and run on its platform worker through
|
||||
// Homebrew's elevated exec service. Keep ES5 syntax for webOS's Node 8.
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var selected = JSON.parse(process.argv[1]);
|
||||
var APP = 'com.ecklf.luma';
|
||||
var STATE = '/var/lib/luma-home';
|
||||
var roots = [
|
||||
'/media/developer/apps/usr/palm/applications',
|
||||
'/media/cryptofs/apps/usr/palm/applications',
|
||||
'/usr/palm/applications'
|
||||
];
|
||||
|
||||
if (
|
||||
typeof selected.directory !== 'string' ||
|
||||
!roots.some(function (root) {
|
||||
return selected.directory === root + '/' + APP;
|
||||
})
|
||||
) {
|
||||
throw new Error('Invalid Luma installation directory');
|
||||
}
|
||||
if (typeof selected.background !== 'string' || !/^[a-z0-9-]{1,32}$/.test(selected.background)) {
|
||||
throw new Error('Invalid splash background');
|
||||
}
|
||||
var appDir = selected.directory;
|
||||
var app = JSON.parse(fs.readFileSync(appDir + '/appinfo.json', 'utf8'));
|
||||
if (app.id !== APP || app.type !== 'native' || app.main !== 'luma') {
|
||||
throw new Error('Splash updates require an installed native Luma package');
|
||||
}
|
||||
// Read the selection already loaded by the native app, rather than trying to
|
||||
// rediscover its preferences from outside the jail. Never copy the default over
|
||||
// a working splash if an update is missing the selected image.
|
||||
var image = fs.readFileSync(path.join(appDir, 'splash', selected.background + '.png'));
|
||||
|
||||
function replace(file) {
|
||||
try {
|
||||
if (fs.readFileSync(file).equals(image)) return;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
fs.writeFileSync(file + '.new', image, { mode: 420 });
|
||||
fs.renameSync(file + '.new', file);
|
||||
}
|
||||
|
||||
// SAM's normal tile uses the installed manifest's splash.png. Updating only
|
||||
// the staged Home copy leaves every normal launch on the package default.
|
||||
replace(appDir + '/splash.png');
|
||||
var config = null;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(STATE + '/state.json', 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
if (config && config.enabled && config.appDir === appDir) {
|
||||
replace(STATE + '/app/splash.png');
|
||||
}
|
||||
console.log(JSON.stringify({ returnValue: true, background: selected.background }));
|
||||
Reference in new issue
Block a user