booletwa
Version:
Generate TWA projects from a Web Manifest
333 lines (300 loc) • 12 kB
text/typescript
/*
* Copyright 2019 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as extractZip from 'extract-zip';
import * as fs from 'fs';
import {join} from 'path';
import {promisify} from 'util';
import {exec, execFile, spawn} from 'child_process';
import {x as extractTar} from 'tar';
import {WebManifestIcon, WebManifestJson} from './types/WebManifest';
import {Log} from './Log';
import {Orientation} from './TwaManifest';
import {fetchUtils} from './FetchUtils';
const execPromise = promisify(exec);
const execFilePromise = promisify(execFile);
const extractZipPromise = promisify(extractZip);
// Regex for disallowed characters on Android Packages, as per
// https://developer.android.com/guide/topics/manifest/manifest-element.html#package
const DISALLOWED_ANDROID_PACKAGE_CHARS_REGEX = /[^a-zA-Z0-9_\.]/g;
const VALID_PACKAGE_ID_SEGMENT_REGEX = /^[a-zA-Z][A-Za-z0-9_]*$/;
// List of keywords for Java 11, as listed at
// https://docs.oracle.com/javase/specs/jls/se11/html/jls-3.html#jls-3.9.
const JAVA_KEYWORDS = [
'abstract', 'assert', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class', 'const',
'continue', 'default', 'do', 'double', 'else', 'enum', 'extends', 'final', 'finally', 'float',
'for', 'goto', 'if', 'implements', 'import', 'instanceof', 'int', 'interface', 'long', 'native',
'new', 'package', 'private', 'protected', 'public', 'return', 'short', 'static', 'strictfp',
'super', 'switch', 'synchronized', 'this', 'throw', 'throws', 'transient', 'try', 'void',
'volatile', 'while',
];
export async function execute(
cmd: string[], env: NodeJS.ProcessEnv, log?: Log): Promise<{stdout: string; stderr: string}> {
const joinedCmd = cmd.join(' ');
if (log) {
log.debug(`Executing shell: ${joinedCmd}`);
}
return await execPromise(joinedCmd, {env: env});
}
export async function executeFile(
cmd: string, args: string[], env: NodeJS.ProcessEnv, log?: Log, cwd?: string,
): Promise<{stdout: string; stderr: string}> {
if (log) {
log.debug(`Executing command ${cmd} with args ${args}`);
}
return await execFilePromise(cmd, args, {env: env, cwd: cwd});
}
export async function unzipFile(
zipFile: string, destinationPath: string, deleteZipWhenDone = false): Promise<void> {
await extractZipPromise(zipFile, {dir: destinationPath});
if (deleteZipWhenDone) {
fs.unlinkSync(zipFile);
}
}
export async function untar(
tarFile: string, destinationPath: string, deleteZipWhenDone = false): Promise<void> {
console.log(`Extracting ${tarFile} to ${destinationPath}`);
await extractTar({
file: tarFile,
cwd: destinationPath,
unlink: true,
});
if (deleteZipWhenDone) {
fs.unlinkSync(tarFile);
}
}
export function execInteractive(
cwd: string, args: string[], env: NodeJS.ProcessEnv): Promise<number> {
const shell = spawn(`"${cwd}"`, args, {
stdio: 'inherit',
env: env,
shell: true,
});
return new Promise((resolve, reject) => {
shell.on('close', (code) => {
if (code === 0) {
resolve(code);
} else {
reject(code);
}
});
});
}
/**
* Fetches data for the largest icon from the web app manifest with a given purpose.
* @param {Array<WebManifestIcon>|undefined} icons List of the manifest icons.
* @param {string} purpose Purpose filter that the icon must match.
* @param {number} minSize The minimum required icon size enforced id provided.
*/
export function findSuitableIcon(
icons: WebManifestIcon[] | undefined, purpose: string, minSize = 0): WebManifestIcon | null {
if (icons == undefined || icons.length === 0) {
return null;
}
let largestIcon: WebManifestIcon | null = null;
let largestIconSize = 0;
for (const icon of icons) {
const size = (icon.sizes || '0x0').split(' ')
.map((size) => Number.parseInt(size, 10))
.reduce((max, size) => Math.max(max, size), 0);
const purposes = new Set((icon.purpose || 'any').split(' '));
if (purposes.has(purpose) && (!largestIcon || largestIconSize < size)) {
largestIcon = icon;
largestIconSize = size;
}
}
if (largestIcon === null || (minSize > 0 && largestIconSize < minSize)) {
return null;
}
largestIcon.size = largestIconSize;
return largestIcon;
}
/**
* Generates an Android Application Id / Package Name, using the reverse hostname as a base
* and appending `.twa` to the end.
*
* Replaces invalid characters, as described in the Android documentation with `_`.
*
* https://developer.android.com/guide/topics/manifest/manifest-element.html#package
*
* @param {String} host the original hostname
*/
export function generatePackageId(host: string): string | null {
host = host.trim();
if (host.length === 0) {
return null;
}
const parts = host.split('.').reverse();
const packageId = [];
for (const part of parts) {
if (part.trim().length === 0) {
continue;
}
// Package names cannot contain Java keywords. The recommendation is adding an '_' before the
// keyword. See https://docs.oracle.com/javase/tutorial/java/package/namingpkgs.html.
if (JAVA_KEYWORDS.includes(part)) {
packageId.push('_' + part);
continue;
}
packageId.push(part);
}
if (packageId.length === 0) {
return null;
}
packageId.push('twa');
return packageId.join('.').replace(DISALLOWED_ANDROID_PACKAGE_CHARS_REGEX, '_');
}
/**
* Validates if a string is not null and not empty.
* @param input the string to be validated
* @param fieldName the field represented by the string
* @returns {string | null} a description of the error or null if no erro is found.
*/
export function validateNotEmpty(
input: string | null | undefined, fieldName: string): string | null {
if (input === null || input === undefined || input.trim().length <= 0) {
return `${fieldName} cannot be empty`;
}
return null;
}
/**
* Validates a Package Id, according to the documentation at:
* https://developer.android.com/studio/build/application-id
*
* Rules summary for the Package Id:
* - It must have at least two segments (one or more dots).
* - Each segment must start with a letter [a-zA-Z].
* - All characters must be alphanumeric or an underscore [a-zA-Z0-9_].
*
* @param {string} input the package name to be validated
* @returns {string | null} a description of the error or null if no erro is found.
*/
export function validatePackageId(input: string): string | null {
const error = validateNotEmpty(input, 'packageId');
if (error !== null) {
return error;
}
const parts = input.split('.');
if (parts.length < 2) {
return 'packageId must have at least 2 sections separated by "."';
}
for (const part of parts) {
// Package names cannot contain Java keywords. The recommendation is adding an '_' before the
// keyword. See https://docs.oracle.com/javase/tutorial/java/package/namingpkgs.html.
if (JAVA_KEYWORDS.includes(part)) {
return `Invalid packageId section: "${part}". ${part} is a Java keyword and cannot be used` +
'as a package section. Consider adding an "_" before the section name.';
}
if (part.match(VALID_PACKAGE_ID_SEGMENT_REGEX) === null) {
return `Invalid packageId section: "${part}". Only alphanumeric characters and ` +
'underscore [a-zA-Z0-9_] are allowed in packageId sections. Each section must ' +
'start with a letter [a-zA-Z]';
}
}
return null;
}
/**
* Removes a file or directory. If the path is a directory, recursively deletes files and
* directories inside it.
*/
export async function rmdir(path: string): Promise<void> {
if (!fs.existsSync(path)) {
return;
}
const stat = await fs.promises.stat(path);
// This is a regular file. Just delete it.
if (stat.isFile()) {
await fs.promises.unlink(path);
return;
}
// This is a directory. We delete files and sub directories inside it, then delete the
// directory itself.
const entries = fs.readdirSync(path);
await Promise.all(entries.map((entry) => rmdir(join(path, entry))));
await fs.promises.rmdir(path);
};
/**
* Given a Web Manifest's URL, the function returns the web manifest as a JSON object.
*
* @param {URL} webManifestUrl the URL where the Web Manifest is available.
* @returns {Promise<WebManifestJson}
*/
export async function getWebManifest(webManifestUrl: URL): Promise<WebManifestJson> {
const response = await fetchUtils.fetch(webManifestUrl.toString());
if (response.status !== 200) {
throw new Error(`Failed to download Web Manifest ${webManifestUrl}. ` +
`Responded with status ${response.status}`);
}
return await response.json();
}
/**
* Given a JSON string, the function returns an escaped representation of the string.
* eg: Turns every " instance into \\".
*
* @param {string} stringToReplace the string before the manipulation.
* @returns {string} the string after the manipulation.
*/
export function escapeJsonString(stringToReplace: string): string {
// The 'g' flag is for replacing all of the instances.
const regExp = new RegExp('\"', 'g');
return stringToReplace.replace(regExp, '\\\\\"');
}
// This is the value of the screenOrientation for the LauncherActivity which determines the
// orientation of the splash screen.
// This methods maps the Web Manifest orientation to the android screenOrientation:
// - "default" => "unspecified"
// - "any" => "unspecified"
// - "natural " => "unspecified"
// - "portrait" => "userPortrait"
// - "portrait-primary" => "portrait"
// - "portrait-secondary" => "reversePortrait"
// - "landscape" => "userLandscape"
// - "landscape-primary" => "landscape"
// - "landscape-secondary" => "reverseLandscape"
//
// For more details, check the web orientation lock documentation at:
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/lockOrientation
//
// And the Android screenOrientation at:
// https://developer.android.com/guide/topics/manifest/activity-element#screen
//
export function toAndroidScreenOrientation(orientation: Orientation): string {
switch (orientation) {
case 'portrait': return 'ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT';
case 'portrait-primary': return 'ActivityInfo.SCREEN_ORIENTATION_PORTRAIT';
case 'portrait-secondary': return 'ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT';
case 'landscape': return 'ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE';
case 'landspace-primary': return 'ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE';
case 'landscape-secondary': return 'ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE';
default: return 'ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED';
}
}
/**
* Escapes a string that will be written to the Gradle file. The characters need to be escaped
* multiple times, as they also need to be escaped inside the Gradle file.
*
* As an example, "Andre's Code" needs to be written as "Andre\\\'s Code" to the Gradle file, so
* it is properly escaped when passed to AAPT.
*/
export function escapeGradleString(input: string): string {
return input.replace(/[\\']/g, '\\\\\\$&');
}
/**
* Escapes a string that will be used inside a double quoted block in the shell. The characters
* ", $, `, and \ need escaping even when the string is surrounded by double quotes.
*/
export function escapeDoubleQuotedShellString(input: string): string {
return input.replace(/([\$"`\\])/g, '\\$1');
}