zombiebox-platform-webos
Version:
LG webOS Smart TV support abstraction layer for ZombieBox framework
590 lines (512 loc) • 14.5 kB
JavaScript
/*
* This file is part of the ZombieBox package.
*
* Copyright © 2014-2020, Interfaced
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
const crypto = require('crypto');
const fse = require('fs-extra');
const imageSize = require('image-size');
const path = require('path');
const inquirer = require('inquirer');
const {AbstractPlatform, utils: {mergeConfigs}, logger: zbLogger} = require('zombiebox');
const {
getInstalledApps,
build,
install,
launch,
inspect,
uninstall
} = require('./cli/ares');
const logger = zbLogger.createChild('webOS');
/**
*/
class PlatformWebOS extends AbstractPlatform {
/**
* @override
*/
getName() {
return 'webos';
}
/**
* @override
*/
getSourcesDir() {
return path.join(__dirname, 'lib');
}
/**
* @override
*/
getConfig() {
return {
platforms: {
webos: {
toolsDir: null
}
},
include: [
{
name: 'webOS PalmSystem',
externs: [
path.resolve(__dirname, 'externs', 'palmsystem.js'),
path.resolve(__dirname, 'externs', 'palmservicebridge.js')
]
}
]
};
}
/**
* @override
*/
buildCLI(yargs, app) {
const config = app.getConfig();
const distPath = app.getPathHelper().getDistDir({
baseDir: config.project.dist,
version: app.getAppVersion(),
platformName: 'webos'
});
const toolsDir = config.platforms.webos.toolsDir;
/**
* @param {string} toolsDir
* @param {string} deviceName
* @return {Promise<string>}
*/
const selectAppFromDevice = async (toolsDir, deviceName) => {
const installedApps = await getInstalledApps(toolsDir, deviceName);
logger.silly(`Installed apps: ${installedApps.join(', ')}`);
if (!installedApps.length) {
throw new Error('No apps installed on device');
}
const {appId} = await inquirer.prompt({
type: 'list',
name: 'appId',
message: 'Select which application to run (sorted from newest to oldest)',
choices: installedApps.reverse()
});
return appId;
};
/**
* @param {Yargs} yargs
* @return {Yargs}
*/
const demandAppId = (yargs) =>
yargs
.positional(
'appId',
{
describe: 'Application ID to use, if not provided will be detected',
alias: 'app-id',
type: 'string'
}
)
.middleware(
async (argv) => {
if (!argv.appId) {
logger.info('Application identifier was not provided.');
try {
argv.appId = await this._findAppId(distPath);
logger.info(`Using application ID ${argv.appId} from local build folder`);
} catch (e) {
logger.error(`Could not extract application ID from local build: ${e.message}`);
argv.appId = await selectAppFromDevice(toolsDir, argv.device);
}
}
}
);
/**
* @param {Yargs} yargs
* @return {Yargs}
*/
const demandDevice = (yargs) => yargs
.positional('device', {
describe: 'Device name',
type: 'string'
});
return yargs
.command(
'install <device>',
'Install app on a device',
demandDevice,
async ({device}) => {
logger.verbose(`Installing application`);
const ipk = await this._findIpk(distPath);
logger.debug(`Found ipk file: ${ipk}`);
await install(toolsDir, ipk, device);
logger.info(`Installation successful`);
}
)
.command(
'launch <device> [appId]',
'Launch app on a device',
(yargs) => {
demandDevice(yargs);
demandAppId(yargs);
},
async ({device, appId}) => {
logger.verbose(`Launching ${appId} on ${device}`);
await launch(toolsDir, appId, device);
logger.info(`Application launched`);
}
)
.command(
'inspect <device> [appId]',
'Inspect app on a device',
(yargs) => {
demandDevice(yargs);
demandAppId(yargs);
},
async ({device, appId}) => {
logger.verbose(`Starting ${appId} on ${device} with inspector`);
const debuggerUrl = await inspect(toolsDir, appId, device);
logger.output(`Debugger url: ${debuggerUrl}`);
}
)
.command(
'uninstall <device> [appId]',
'Remove installed app from a device',
(yargs) => {
demandDevice(yargs);
demandAppId(yargs);
},
async ({device, appId}) => {
logger.verbose(`Uninstalling ${appId} from ${device}`);
await uninstall(toolsDir, appId, device);
logger.info(`Application uninstalled`);
}
)
.command(
'list <device>',
'List installed applications on a device',
demandDevice,
async ({device}) => {
logger.verbose(`Querying installed apps on ${device}`);
const apps = await getInstalledApps(toolsDir, device);
if (apps.length) {
logger.output(`Installed applications: \n\t${apps.join('\n\t')}`);
} else {
logger.output('No apps installed');
}
}
)
.command(
'clean <device>',
'Remove all installed apps from a device',
demandDevice,
async ({device}) => {
logger.verbose(`Cleaning installed apps from ${device}`);
const installedApps = await getInstalledApps(toolsDir, device);
if (!installedApps.length) {
logger.output('No apps installed, nothing to clean');
return;
}
const {confirmed} = await inquirer.prompt({
type: 'checkbox',
name: 'confirmed',
message: 'Following applications will be removed:',
choices: installedApps,
default: installedApps
});
await Promise.all(confirmed.map((appId) => uninstall(toolsDir, appId, device)));
logger.info(`Cleanup done`);
}
)
.demandCommand(1, 1, 'No command specified')
.fail((message, error) => {
if (message) {
logger.error(message);
}
if (error instanceof Error) {
logger.error(error.toString());
logger.debug(error.stack);
}
yargs.showHelp();
process.exit(1);
});
}
/**
* @override
*/
async pack(app, distDir) {
const config = app.getConfig();
const {name, version} = app.getAppPackageJson();
/**
* @type {PlatformWebOS.Config}
*/
const originalUserConfig = config.platforms.webos;
const defaultAppInfo = this._createDefaultAppInfo(name, version);
const userImgConfig = await this._createUserImgConfig(originalUserConfig.img);
const images = await this._checkAndFilterImages(userImgConfig);
const defaultImgConfig = this._generateDefaultImageFullPathObject(__dirname);
const resultImgConfig = mergeConfigs(defaultImgConfig, images);
await this._copyImages(distDir, resultImgConfig);
const userAppInfo = originalUserConfig.appinfo;
const appInfo = userAppInfo ? mergeConfigs(defaultAppInfo, userAppInfo) : defaultAppInfo;
const resultAppInfo = mergeConfigs(
appInfo,
PlatformWebOS.ImageDistPath
);
await fse.writeJson(path.join(distDir, 'appinfo.json'), resultAppInfo);
await build(config.platforms.webos.toolsDir, distDir);
}
/**
* @param {(string|Object)=} userImgConfig
* @return {Promise<Object<string, string>>}
* @protected
*/
_createUserImgConfig(userImgConfig) {
return Promise.resolve(
typeof userImgConfig === 'object' ?
userImgConfig :
typeof userImgConfig === 'string' ?
fse.readdir(userImgConfig)
.then((filenames) => {
const fullPaths = filenames.map((name) => path.resolve(userImgConfig, name));
return this._convertPathsToDistPathsByImageName(fullPaths);
}) :
{}
);
}
/**
* @param {string} name
* @param {string} version
* @return {Object}
* @protected
*/
_createDefaultAppInfo(name, version) {
return {
'id': `com.zombiebox.${name}-${this._generateRandomString()}`,
'title': name,
'version': version,
'vendor': 'Interfaced',
'type': 'web',
'disableBackHistoryAPI': true,
'iconColor': '#3c3c3c',
'main': 'index.html'
};
}
/**
* @param {Object<string, string>} files
* @return {Object<PlatformWebOS.ImageName, string>}
* @protected
*/
async _checkAndFilterImages(files) {
/**
* @param {boolean} condition
* @param {function(): Error} getRejectReason
* @return {Promise}
*/
const promisifyBoolean = (condition, getRejectReason) => condition ?
Promise.resolve() :
Promise.reject(getRejectReason());
/**
* @param {Array<function(): Promise>} checks
* @return {Promise}
*/
const check = (checks) => {
const run = (queue) =>
queue.shift()()
.then(() => queue.length && run(queue) || undefined);
return run([...checks]);
};
const createImageChecks = (imageName, imagePath) => {
const basename = path.basename(imagePath);
const extension = path.extname(basename.toLowerCase());
const requiredSize = PlatformWebOS.ImageSize[imageName];
return [
() => promisifyBoolean(!!requiredSize, () => new Error(`Unknown image "${imageName}"`)),
() => promisifyBoolean(extension === '.png', () => new Error(`${imageName} is not a png file`)),
() => fse.exists(imagePath)
.then((exists) =>
exists ?
Promise.resolve() :
Promise.reject(new Error(`Could not find "${imageName}" by path ${imagePath}`))
),
() => new Promise((resolve, reject) => {
try {
const {width, height} = imageSize(imagePath);
const [requiredWidth, requiredHeight] = requiredSize;
if (width !== requiredWidth || height !== requiredHeight) {
reject(new Error(
`Incorrect size of ${basename}: ` +
`expected ${requiredWidth}x${requiredHeight}, ` +
`got ${width}x${height}`
));
}
} catch (e) {
reject(new Error(`Failed to read size of ${basename}: ${e.message}`));
}
resolve();
})
];
};
return Object.entries(files)
.reduce(async (accumulatorPromise, [imageName, imagePath]) => {
const accumulator = await accumulatorPromise;
try {
await check(createImageChecks(imageName, imagePath));
accumulator[imageName] = imagePath;
} catch (warning) {
logger.warn(warning);
}
return Promise.resolve(accumulator);
}, Promise.resolve({}));
}
/**
* @return {string}
* @protected
*/
_generateRandomString() {
return crypto.randomBytes(3).toString('hex');
}
/**
* @param {Array<string>} paths
* @return {Object<PlatformWebOS.ImageName, string>}
* @protected
*/
_convertPathsToDistPathsByImageName(paths) {
return Object.entries(PlatformWebOS.ImageDistPath)
.reduce((accumulator, [imageName, filename]) => {
const imagePath = paths.find((imagePath) => imagePath.includes(filename));
if (imagePath) {
accumulator[imageName] = imagePath;
}
return accumulator;
}, {});
}
/**
* @param {string} distDir
* @param {Object<PlatformWebOS.ImageName, string>} files
* @return {Promise}
* @protected
*/
async _copyImages(distDir, files) {
const imagesDist = path.join(distDir, 'img');
await fse.ensureDir(imagesDist);
const copyPromises = Object.values(files)
.map((sourcePath) => {
const destinationPath = path.join(imagesDist, path.basename(sourcePath));
logger.silly(`Copying, ${sourcePath} to ${destinationPath}`);
return fse.copy(sourcePath, destinationPath);
});
return Promise.all(copyPromises);
}
/**
* @param {string} basePath
* @return {Object<PlatformWebOS.ImageName, string>}
* @protected
*/
_generateDefaultImageFullPathObject(basePath) {
return Object.entries(PlatformWebOS.ImageDistPath)
.reduce((accumulator, [imageName, imagePath]) => {
accumulator[imageName] = path.join(basePath, imagePath);
return accumulator;
}, {});
}
/**
* @param {string} distPath
* @return {Object}
* @protected
*/
async _getAppInfo(distPath) {
const rawAppInfo = await fse.readFile(path.join(distPath, 'appinfo.json'));
return JSON.parse(rawAppInfo);
}
/**
* @param {string} distPath
* @return {string}
* @protected
*/
async _findAppId(distPath) {
const {id} = await this._getAppInfo(distPath);
if (!id) {
throw new Error('There is no Application ID in appinfo.json');
}
return id;
}
/**
* @param {string} distPath
* @return {string}
* @protected
*/
async _findIpk(distPath) {
const distFilenames = await fse.readdir(distPath);
const ipkFilename = distFilenames.find((filename) => filename.endsWith('.ipk'));
if (!ipkFilename) {
throw new Error(`There is no .ipk file in ${distPath}`);
}
return path.join(distPath, ipkFilename);
}
}
/**
* @typedef {?}
*/
let Yargs;
/**
* @see http://webosose.org/develop/configuration-files/appinfo-json/
* @typedef {{
* id: (string|undefined),
* title: (string|undefined),
* main: (string|undefined),
* icon: (string|undefined),
* largeIcon: (string|undefined),
* type: (string|undefined),
* vendor: (string|undefined),
* version: (string|undefined),
* appDescription: (string|undefined),
* resolution: (string|undefined),
* iconColor: (string|undefined),
* splashBackground: (string|undefined),
* transparent: (boolean|undefined),
* requiredMemory: (number|undefined)
* }}
*/
PlatformWebOS.AppInfo;
/**
* @typedef {{
* img: Object<PlatformWebOS.ImageName, string>,
* appinfo: Object
* }}
*/
PlatformWebOS.Config;
/**
* @enum {string}
*/
PlatformWebOS.ImageName = {
ICON: 'icon',
LARGE_ICON: 'largeIcon',
BACKGROUND_IMAGE: 'bgImage',
SPLASH_SCREEN_BACKGROUND: 'splashBackground'
};
/**
* @type {Object<PlatformWebOS.ImageName, Array<number>>}
*/
PlatformWebOS.ImageSize = {
[PlatformWebOS.ImageName.ICON]: [80, 80],
[PlatformWebOS.ImageName.LARGE_ICON]: [130, 130],
[PlatformWebOS.ImageName.BACKGROUND_IMAGE]: [1920, 1080],
[PlatformWebOS.ImageName.SPLASH_SCREEN_BACKGROUND]: [1920, 1080]
};
/**
* @type {Object<PlatformWebOS.ImageName, string>}
*/
PlatformWebOS.ImageDefaultFilename = {
[PlatformWebOS.ImageName.ICON]: 'icon.png',
[PlatformWebOS.ImageName.LARGE_ICON]: 'large-icon.png',
[PlatformWebOS.ImageName.BACKGROUND_IMAGE]: 'bg-image.png',
[PlatformWebOS.ImageName.SPLASH_SCREEN_BACKGROUND]: 'splash-background.png'
};
/**
* @type {Object<PlatformWebOS.ImageName, string>}
*/
PlatformWebOS.ImageDistPath = {
[PlatformWebOS.ImageName.ICON]: 'img/icon.png',
[PlatformWebOS.ImageName.LARGE_ICON]: 'img/large-icon.png',
[PlatformWebOS.ImageName.BACKGROUND_IMAGE]: 'img/bg-image.png',
[PlatformWebOS.ImageName.SPLASH_SCREEN_BACKGROUND]: 'img/splash-background.png'
};
/**
* @type {PlatformWebOS}
*/
module.exports = PlatformWebOS;