Merge Luma launcher fork into monorepo
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s

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:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 11:52:55 +03:00
1 parent bc3ca0f3b9
commit cbea5a322f
204 files changed
+25056 -65

No files matched your search

+34
View File
@@ -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
View File
@@ -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
View File
@@ -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}"
+100
View File
@@ -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 }));
+57
View File
@@ -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 }));