UNPKG

booletwa

Version:

Generate TWA projects from a Web Manifest

333 lines (300 loc) 12 kB
/* * 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'); }