UNPKG

@bubblewrap/core

Version:

Core Library to generate, build and sign TWA projects

309 lines (308 loc) 12.4 kB
"use strict"; /* * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.execute = execute; exports.executeFile = executeFile; exports.unzipFile = unzipFile; exports.untar = untar; exports.execInteractive = execInteractive; exports.findSuitableIcon = findSuitableIcon; exports.generatePackageId = generatePackageId; exports.validateNotEmpty = validateNotEmpty; exports.validatePackageId = validatePackageId; exports.rmdir = rmdir; exports.getWebManifest = getWebManifest; exports.escapeJsonString = escapeJsonString; exports.toAndroidScreenOrientation = toAndroidScreenOrientation; exports.escapeGradleString = escapeGradleString; exports.escapeDoubleQuotedShellString = escapeDoubleQuotedShellString; const extractZip = require("extract-zip"); const fs = require("fs"); const path_1 = require("path"); const util_1 = require("util"); const child_process_1 = require("child_process"); const tar_1 = require("tar"); const FetchUtils_1 = require("./FetchUtils"); const execPromise = (0, util_1.promisify)(child_process_1.exec); const execFilePromise = (0, util_1.promisify)(child_process_1.execFile); const extractZipPromise = (0, util_1.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', ]; async function execute(cmd, env, log) { const joinedCmd = cmd.join(' '); if (log) { log.debug(`Executing shell: ${joinedCmd}`); } return await execPromise(joinedCmd, { env: env }); } async function executeFile(cmd, args, env, log, cwd) { if (log) { log.debug(`Executing command ${cmd} with args ${args}`); } return await execFilePromise(cmd, args, { env: env, cwd: cwd, shell: true }); } async function unzipFile(zipFile, destinationPath, deleteZipWhenDone = false) { await extractZipPromise(zipFile, { dir: destinationPath }); if (deleteZipWhenDone) { fs.unlinkSync(zipFile); } } async function untar(tarFile, destinationPath, deleteZipWhenDone = false) { console.log(`Extracting ${tarFile} to ${destinationPath}`); await (0, tar_1.x)({ file: tarFile, cwd: destinationPath, unlink: true, }); if (deleteZipWhenDone) { fs.unlinkSync(tarFile); } } function execInteractive(cwd, args, env) { const shell = (0, child_process_1.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. */ function findSuitableIcon(icons, purpose, minSize = 0) { if (icons == undefined || icons.length === 0) { return null; } let largestIcon = 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 */ function generatePackageId(host) { 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. */ function validateNotEmpty(input, fieldName) { 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. */ function validatePackageId(input) { 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. */ async function rmdir(path) { 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((0, path_1.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} */ async function getWebManifest(webManifestUrl) { const response = await FetchUtils_1.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. */ function escapeJsonString(stringToReplace) { // 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 // function toAndroidScreenOrientation(orientation) { 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. */ function escapeGradleString(input) { 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. */ function escapeDoubleQuotedShellString(input) { return input.replace(/([\$"`\\])/g, '\\$1'); }