UNPKG

@bubblewrap/core

Version:

Core Library to generate, build and sign TWA projects

437 lines (436 loc) 23.3 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. */ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.TwaManifest = exports.Orientations = exports.DisplayModes = void 0; exports.asDisplayMode = asDisplayMode; exports.asOrientation = asOrientation; const fs = require("fs"); const FetchUtils_1 = require("./FetchUtils"); const util_1 = require("./util"); const Color = require("color"); const Log_1 = require("./Log"); const ProtocolHandler_1 = require("./types/ProtocolHandler"); const ShortcutInfo_1 = require("./ShortcutInfo"); const FileHandler_1 = require("./types/FileHandler"); // The minimum size needed for the app icon. const MIN_ICON_SIZE = 512; // As described on https://developer.chrome.com/apps/manifest/name#short_name const SHORT_NAME_MAX_SIZE = 12; // The minimum size needed for the notification icon const MIN_NOTIFICATION_ICON_SIZE = 48; // Supported display modes for TWA const DISPLAY_MODE_VALUES = ['standalone', 'minimal-ui', 'fullscreen', 'fullscreen-sticky', 'browser']; exports.DisplayModes = [...DISPLAY_MODE_VALUES]; function asDisplayMode(input) { return DISPLAY_MODE_VALUES.includes(input) ? input : null; } // Possible values for screen orientation, as defined in `android-browser-helper`: // https://github.com/GoogleChrome/android-browser-helper/blob/alpha/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivityMetadata.java#L191-L216 const ORIENTATION_VALUES = ['default', 'any', 'natural', 'landscape', 'portrait', 'portrait-primary', 'portrait-secondary', 'landscape-primary', 'landscape-secondary']; exports.Orientations = [...ORIENTATION_VALUES]; function asOrientation(input) { if (!input) { return null; } return ORIENTATION_VALUES.includes(input) ? input : null; } // Default values used on the Twa Manifest const DEFAULT_SPLASHSCREEN_FADEOUT_DURATION = 300; const DEFAULT_APP_NAME = 'My TWA'; const DEFAULT_DISPLAY_MODE = 'standalone'; const DEFAULT_THEME_COLOR = '#FFFFFF'; const DEFAULT_THEME_COLOR_DARK = '#000000'; const DEFAULT_NAVIGATION_COLOR = '#000000'; const DEFAULT_NAVIGATION_DIVIDER_COLOR = '#00000000'; const DEFAULT_BACKGROUND_COLOR = '#FFFFFF'; const DEFAULT_APP_VERSION_CODE = 1; const DEFAULT_APP_VERSION_NAME = DEFAULT_APP_VERSION_CODE.toString(); const DEFAULT_MIN_SDK_VERSION = 21; const DEFAULT_SIGNING_KEY_PATH = './android.keystore'; const DEFAULT_SIGNING_KEY_ALIAS = 'android'; const DEFAULT_ENABLE_NOTIFICATIONS = true; const DEFAULT_GENERATOR_APP_NAME = 'unknown'; const DEFAULT_ORIENTATION = 'default'; /** * A Manifest used to generate the TWA Project * * applicationId: '<%= packageId %>', * hostName: '<%= host %>', // The domain being opened in the TWA. * launchUrl: '<%= startUrl %>', // The start path for the TWA. Must be relative to the domain. * name: '<%= name %>', // The name shown on the Android Launcher. * display: '<%= display %>', // The display mode for the TWA. * themeColor: '<%= themeColor %>', // The color used for the status bar. * themeColorDark: '<%= themeColorDark %>', // The color used for the dark status bar. * navigationColor: '<%= navigationColor %>', // The color used for the navigation bar. * navigationColorDark: '<%= navigationColorDark %>', // The color used for the dark navbar. * navigationDividerColor: '<%= navigationDividerColor %>', // The color used for the * navbar divider. * navigationDividerColorDark: '<%= navigationDividerColorDark %>', // The color used for the dark * navbar divider. * backgroundColor: '<%= backgroundColor %>', // The color used for the splash screen background. * enableNotifications: false, // Set to true to enable notification delegation. * enableSiteSettingsShortcut: true, // Set to false to disable the shortcut into site settings. * // Add shortcuts for your app here. Every shortcut must include the following fields: * // - name: String that will show up in the shortcut. * // - shortName: Shorter string used if |name| is too long. * // - url: Absolute path of the URL to launch the app with (e.g '/create'). * // - chosenIconUrl: Name of the resource in the drawable folder to use as an icon. * shortcuts: [ * // Insert shortcuts here, for example: * // { name: 'Open SVG', shortName: 'Open', url: '/open', chosenIconUrl: 'https://example.com/example.svg' } * ], * // The duration of fade out animation in milliseconds to be played when removing splash screen. * splashScreenFadeOutDuration: 300 * isChromeOSOnly: false, // Setting to true will enable a feature that prevents non-ChromeOS devices * from installing the app. * isMetaQuest: false, // Setting to true will generate the build compatible with Meta Quest devices. * serviceAccountJsonFile: '<%= serviceAccountJsonFile %>', // The service account used to communicate with * Google Play. * */ class TwaManifest { constructor(data) { var _a, _b, _c, _d; this.packageId = data.packageId; this.host = data.host; this.name = data.name; // Older manifests may not have this field: this.launcherName = data.launcherName || data.name; // Older manifests may not have this field: this.display = asDisplayMode(data.display) || DEFAULT_DISPLAY_MODE; this.themeColor = new Color(data.themeColor); this.themeColorDark = new Color((_a = data.themeColorDark) !== null && _a !== void 0 ? _a : DEFAULT_THEME_COLOR_DARK); this.navigationColor = new Color(data.navigationColor); this.navigationColorDark = new Color((_b = data.navigationColorDark) !== null && _b !== void 0 ? _b : DEFAULT_NAVIGATION_COLOR); this.navigationDividerColor = new Color((_c = data.navigationDividerColor) !== null && _c !== void 0 ? _c : DEFAULT_NAVIGATION_DIVIDER_COLOR); this.navigationDividerColorDark = new Color((_d = data.navigationDividerColorDark) !== null && _d !== void 0 ? _d : DEFAULT_NAVIGATION_COLOR); this.backgroundColor = new Color(data.backgroundColor); this.enableNotifications = data.enableNotifications; this.startUrl = data.startUrl; this.iconUrl = data.iconUrl; this.maskableIconUrl = data.maskableIconUrl; this.monochromeIconUrl = data.monochromeIconUrl; this.splashScreenFadeOutDuration = data.splashScreenFadeOutDuration; this.signingKey = data.signingKey; this.appVersionName = data.appVersion; this.appVersionCode = data.appVersionCode || DEFAULT_APP_VERSION_CODE; this.shortcuts = (data.shortcuts || []).map((si) => { return new ShortcutInfo_1.ShortcutInfo(si.name, si.shortName, si.url, si.chosenIconUrl, si.chosenMaskableIconUrl, si.chosenMonochromeIconUrl); }); this.generatorApp = data.generatorApp || DEFAULT_GENERATOR_APP_NAME; this.webManifestUrl = data.webManifestUrl ? new URL(data.webManifestUrl) : undefined; this.fallbackType = data.fallbackType || 'customtabs'; this.features = data.features || {}; this.alphaDependencies = data.alphaDependencies || { enabled: false }; this.enableSiteSettingsShortcut = data.enableSiteSettingsShortcut != undefined ? data.enableSiteSettingsShortcut : true; this.isChromeOSOnly = data.isChromeOSOnly != undefined ? data.isChromeOSOnly : false; this.isMetaQuest = data.isMetaQuest != undefined ? data.isMetaQuest : false; this.fullScopeUrl = data.fullScopeUrl ? new URL(data.fullScopeUrl) : undefined; this.minSdkVersion = data.minSdkVersion || DEFAULT_MIN_SDK_VERSION; this.shareTarget = data.shareTarget; this.orientation = data.orientation || DEFAULT_ORIENTATION; this.fingerprints = data.fingerprints || []; this.serviceAccountJsonFile = data.serviceAccountJsonFile; this.additionalTrustedOrigins = data.additionalTrustedOrigins || []; this.retainedBundles = data.retainedBundles || []; this.protocolHandlers = data.protocolHandlers; this.fileHandlers = data.fileHandlers; this.launchHandlerClientMode = data.launchHandlerClientMode; } /** * Turns an TwaManifest into a TwaManifestJson. * * @returns {TwaManifestJson} */ toJson() { return Object.assign({}, this, { themeColor: this.themeColor.hex(), themeColorDark: this.themeColorDark.hex(), navigationColor: this.navigationColor.hex(), navigationColorDark: this.navigationColorDark.hex(), navigationDividerColor: this.navigationDividerColor.hex(), navigationDividerColorDark: this.navigationDividerColorDark.hex(), backgroundColor: this.backgroundColor.hex(), appVersion: this.appVersionName, webManifestUrl: this.webManifestUrl ? this.webManifestUrl.toString() : undefined, fullScopeUrl: this.fullScopeUrl ? this.fullScopeUrl.toString() : undefined, }); } /** * Saves the TWA Manifest to the file-system. * * @param {String} filename the location where the TWA Manifest will be saved. */ async saveToFile(filename) { const json = this.toJson(); await fs.promises.writeFile(filename, JSON.stringify(json, null, 2)); } /** * Validates if the Manifest has all the fields needed to generate a TWA project and if the * values for those fields are valid. * * @returns {string | null} the error, if any field has an error or null if all fields are valid. */ validate() { let error; error = (0, util_1.validateNotEmpty)(this.host, 'host'); if (error != null) { return error; } error = (0, util_1.validateNotEmpty)(this.name, 'name'); if (error != null) { return error; } error = (0, util_1.validateNotEmpty)(this.startUrl, 'startUrl'); if (error != null) { return error; } if (!this.iconUrl) { return 'iconUrl cannot be empty'; } error = (0, util_1.validateNotEmpty)(this.iconUrl, 'iconUrl'); if (error != null) { return error; } return error; } generateShortcuts() { return '[' + this.shortcuts.map((shortcut, i) => shortcut.toString(i)).join(',') + ']'; } /** * Creates a new TwaManifest, using the URL for the Manifest as a base URL and uses the content * of the Web Manifest to generate the fields for the TWA Manifest. * * @param {URL} webManifestUrl the URL where the webmanifest is available. * @param {WebManifest} webManifest the Web Manifest, used as a base for the TWA Manifest. * @returns {TwaManifest} */ static fromWebManifestJson(webManifestUrl, webManifest) { var _a, _b, _c, _d; const icon = (0, util_1.findSuitableIcon)(webManifest.icons, 'any', MIN_ICON_SIZE); const maskableIcon = (0, util_1.findSuitableIcon)(webManifest.icons, 'maskable', MIN_ICON_SIZE); const monochromeIcon = (0, util_1.findSuitableIcon)(webManifest.icons, 'monochrome', MIN_NOTIFICATION_ICON_SIZE); const fullStartUrl = new URL(webManifest['start_url'] || '/', webManifestUrl); const fullScopeUrl = new URL(webManifest['scope'] || '.', webManifestUrl); const shortcuts = this.getShortcuts(webManifestUrl, webManifest); function resolveIconUrl(icon) { return icon ? new URL(icon.src, webManifestUrl).toString() : undefined; } const processedProtocolHandlers = (0, ProtocolHandler_1.processProtocolHandlers)((_a = webManifest.protocol_handlers) !== null && _a !== void 0 ? _a : [], fullStartUrl, fullScopeUrl); const fileHandlers = (0, FileHandler_1.processFileHandlers)((_b = webManifest.file_handlers) !== null && _b !== void 0 ? _b : [], fullStartUrl, fullScopeUrl); const twaManifest = new TwaManifest({ packageId: (0, util_1.generatePackageId)(webManifestUrl.host) || '', host: webManifestUrl.host, name: webManifest['name'] || webManifest['short_name'] || DEFAULT_APP_NAME, launcherName: webManifest['short_name'] || ((_c = webManifest['name']) === null || _c === void 0 ? void 0 : _c.substring(0, SHORT_NAME_MAX_SIZE)) || DEFAULT_APP_NAME, display: asDisplayMode(webManifest['display']) || DEFAULT_DISPLAY_MODE, themeColor: webManifest['theme_color'] || DEFAULT_THEME_COLOR, themeColorDark: DEFAULT_THEME_COLOR_DARK, navigationColor: DEFAULT_NAVIGATION_COLOR, navigationColorDark: DEFAULT_NAVIGATION_COLOR, navigationDividerColor: DEFAULT_NAVIGATION_DIVIDER_COLOR, navigationDividerColorDark: DEFAULT_NAVIGATION_DIVIDER_COLOR, backgroundColor: webManifest['background_color'] || DEFAULT_BACKGROUND_COLOR, startUrl: fullStartUrl.pathname + fullStartUrl.search, iconUrl: resolveIconUrl(icon), maskableIconUrl: resolveIconUrl(maskableIcon), monochromeIconUrl: resolveIconUrl(monochromeIcon), appVersion: DEFAULT_APP_VERSION_NAME, signingKey: { path: DEFAULT_SIGNING_KEY_PATH, alias: DEFAULT_SIGNING_KEY_ALIAS, }, splashScreenFadeOutDuration: DEFAULT_SPLASHSCREEN_FADEOUT_DURATION, enableNotifications: DEFAULT_ENABLE_NOTIFICATIONS, shortcuts: shortcuts, webManifestUrl: webManifestUrl.toString(), features: {}, shareTarget: TwaManifest.verifyShareTarget(webManifestUrl, webManifest.share_target), orientation: asOrientation(webManifest.orientation) || DEFAULT_ORIENTATION, fullScopeUrl: fullScopeUrl.toString(), protocolHandlers: processedProtocolHandlers, fileHandlers, launchHandlerClientMode: ((_d = webManifest['launch_handler']) === null || _d === void 0 ? void 0 : _d['client_mode']) || '', }); return twaManifest; } static verifyShareTarget(webManifestUrl, shareTarget) { var _a; if (!(shareTarget === null || shareTarget === void 0 ? void 0 : shareTarget.action)) { return undefined; } if ((_a = shareTarget === null || shareTarget === void 0 ? void 0 : shareTarget.params) === null || _a === void 0 ? void 0 : _a.files) { for (const file of shareTarget.params.files) { if (!file.accept) { return undefined; } } } return { ...shareTarget, // Ensure action is an absolute URL. action: new URL(shareTarget.action, webManifestUrl).toString(), }; } /** * Fetches a Web Manifest from the url and uses it as a base for the TWA Manifest. * * @param {String} url the URL where the webmanifest is available * @returns {TwaManifest} */ static async fromWebManifest(url) { const response = await FetchUtils_1.fetchUtils.fetch(url); const webManifest = JSON.parse((await response.text()).trim()); const webManifestUrl = new URL(url); return TwaManifest.fromWebManifestJson(webManifestUrl, webManifest); } /** * Loads a TWA Manifest from the file system. * * @param {String} fileName the location of the TWA Manifest file */ static async fromFile(fileName) { const json = JSON.parse((await fs.promises.readFile(fileName)).toString()); return new TwaManifest(json); } /** * Given a field name, returns the new value of the field. * * @param {string} fieldName the name of the given field. * @param {string[]} fieldsToIgnore the fields which needs to be ignored. * @param {T} oldValue the old value of the field. * @param {T} newValue the new value of the field. * @returns {T} */ static getNewFieldValue(fieldName, fieldsToIgnore, oldValue, newValue) { if (fieldsToIgnore.includes(fieldName)) { return oldValue; } return newValue || oldValue; } /** * Gets the shortcuts from the web manifest. * * @param {URL} webManifestUrl the URL where the webManifest is available. * @param {WebManifest} webManifest the Web Manifest. * @returns {ShortcutInfo[]} */ static getShortcuts(webManifestUrl, webManifest) { const shortcuts = []; for (let i = 0; i < (webManifest.shortcuts || []).length; i++) { const s = webManifest.shortcuts[i]; try { const shortcutInfo = ShortcutInfo_1.ShortcutInfo.fromShortcutJson(webManifestUrl, s); if (shortcutInfo != null) { shortcuts.push(shortcutInfo); } } catch (err) { TwaManifest.log.warn(`Skipping shortcut[${i}] for ${err}.`); } if (shortcuts.length === 4) { break; } } return shortcuts; } /** * @param {string[]} fieldsToIgnore the fields which needs to be ignored. * @param {string} fieldName the name of the given field. * @param {string} oldUrl the url of the old twaManifest. * @param {WebManifestIcon[]} icons the list of icons from the web manifest. * @param {string} iconType the type of the requested icon. * @param {number} iconSize the size of the requested icon. * @param {URL} webManifestUrl the URL where the webManifest is available. * @returns {string | undefined} the new icon url. */ static getNewIconUrl(fieldsToIgnore, fieldName, oldUrl, icons, iconType, iconSize, webManifestUrl) { function resolveIconUrl(icon) { return icon ? new URL(icon.src, webManifestUrl).toString() : undefined; } return (fieldsToIgnore.includes(fieldName)) ? oldUrl : resolveIconUrl((0, util_1.findSuitableIcon)(icons, iconType, iconSize)); } /** * Merges the Twa Manifest with the web manifest. Ignores the specified fields. * * @param {string[]} fieldsToIgnore the fields which needs to be ignored. * @param {URL} webManifestUrl the URL where the webManifest is available. * @param {WebManifest} webManifest the Web Manifest, used as a base for the update of * the TWA Manifest. * @param {TwaManifest} oldTwaManifest current Twa Manifest. * @returns {Promise<TwaManifest>} the new and merged Twa manifest. */ static async merge(fieldsToIgnore, webManifestUrl, webManifest, oldTwaManifest) { var _a, _b, _c, _d, _e, _f; let shortcuts = oldTwaManifest.shortcuts; if (!(fieldsToIgnore.includes('shortcuts'))) { shortcuts = this.getShortcuts(webManifestUrl, webManifest); } const oldTwaManifestJson = oldTwaManifest.toJson(); const iconUrl = this.getNewIconUrl(fieldsToIgnore, 'icons', oldTwaManifestJson.iconUrl, webManifest.icons, 'any', MIN_ICON_SIZE, webManifestUrl); const maskableIconUrl = this.getNewIconUrl(fieldsToIgnore, 'maskableIcons', oldTwaManifestJson.iconUrl, webManifest.icons, 'maskable', MIN_ICON_SIZE, webManifestUrl); const monochromeIconUrl = this.getNewIconUrl(fieldsToIgnore, 'monochromeIcons', oldTwaManifestJson.iconUrl, webManifest.icons, 'monochrome', MIN_NOTIFICATION_ICON_SIZE, webManifestUrl); const protocolHandlersMap = new Map(); for (const handler of (_a = oldTwaManifest.protocolHandlers) !== null && _a !== void 0 ? _a : []) { protocolHandlersMap.set(handler.protocol, handler.url); } if (!(fieldsToIgnore.includes('protocol_handlers'))) { for (const handler of (_b = webManifest.protocol_handlers) !== null && _b !== void 0 ? _b : []) { protocolHandlersMap.set(handler.protocol, handler.url); } ; } const protocolHandlers = Array.from(protocolHandlersMap.entries()).map(([protocol, url]) => { return { protocol, url }; }); const fullStartUrl = new URL(webManifest['start_url'] || '/', webManifestUrl); const fullScopeUrl = new URL(webManifest['scope'] || '.', webManifestUrl); let fileHandlers = oldTwaManifestJson.fileHandlers; if (!(fieldsToIgnore.includes('file_handlers'))) { fileHandlers = (0, FileHandler_1.processFileHandlers)((_c = webManifest.file_handlers) !== null && _c !== void 0 ? _c : [], fullStartUrl, fullScopeUrl); if (fileHandlers.length == 0) { fileHandlers = oldTwaManifestJson.fileHandlers; } } const twaManifest = new TwaManifest({ ...oldTwaManifestJson, name: this.getNewFieldValue('name', fieldsToIgnore, oldTwaManifest.name, webManifest['name'] || webManifest['short_name']), launcherName: this.getNewFieldValue('short_name', fieldsToIgnore, oldTwaManifest.launcherName, webManifest['short_name'] || ((_d = webManifest['name']) === null || _d === void 0 ? void 0 : _d.substring(0, SHORT_NAME_MAX_SIZE))), display: this.getNewFieldValue('display', fieldsToIgnore, oldTwaManifest.display, asDisplayMode(webManifest['display'])), fullScopeUrl: this.getNewFieldValue('fullScopeUrl', fieldsToIgnore, (_e = oldTwaManifest.fullScopeUrl) === null || _e === void 0 ? void 0 : _e.toString(), fullScopeUrl.toString()), themeColor: this.getNewFieldValue('themeColor', fieldsToIgnore, oldTwaManifest.themeColor.hex(), webManifest['theme_color']), backgroundColor: this.getNewFieldValue('backgroundColor', fieldsToIgnore, oldTwaManifest.backgroundColor.hex(), webManifest['background_color']), startUrl: this.getNewFieldValue('startUrl', fieldsToIgnore, oldTwaManifest.startUrl, fullStartUrl.pathname + fullStartUrl.search), iconUrl: iconUrl || oldTwaManifestJson.iconUrl, maskableIconUrl: maskableIconUrl || oldTwaManifestJson.maskableIconUrl, monochromeIconUrl: monochromeIconUrl || oldTwaManifestJson.monochromeIconUrl, shortcuts: shortcuts, protocolHandlers: protocolHandlers, fileHandlers, launchHandlerClientMode: this.getNewFieldValue('launchHandlerClientMode', fieldsToIgnore, oldTwaManifest.launchHandlerClientMode, ((_f = webManifest['launch_handler']) === null || _f === void 0 ? void 0 : _f['client_mode']) || ''), }); return twaManifest; } } exports.TwaManifest = TwaManifest; TwaManifest.log = new Log_1.ConsoleLog('twa-manifest');