UNPKG

booletwa

Version:

Generate TWA projects from a Web Manifest

548 lines (504 loc) 22.1 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'; import * as fs from 'fs'; import {fetchUtils} from './FetchUtils'; import {findSuitableIcon, generatePackageId, validateNotEmpty} from './util'; import Color = require('color'); import {ConsoleLog} from './Log'; import {ShareTarget, WebManifestIcon, WebManifestJson} from './types/WebManifest'; import {ShortcutInfo} from './ShortcutInfo'; import {AppsFlyerConfig} from './features/AppsFlyerFeature'; import {LocationDelegationConfig} from './features/LocationDelegationFeature'; import {PlayBillingConfig} from './features/PlayBillingFeature'; import {FirstRunFlagConfig} from './features/FirstRunFlagFeature'; import {ArCoreConfig} from './features/ArCoreFeature'; // 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', 'fullscreen', 'fullscreen-sticky']; export type DisplayMode = typeof DISPLAY_MODE_VALUES[number]; export const DisplayModes: DisplayMode[] = [...DISPLAY_MODE_VALUES]; export function asDisplayMode(input: string): DisplayMode | null { return DISPLAY_MODE_VALUES.includes(input) ? input as DisplayMode : 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']; export type Orientation = typeof ORIENTATION_VALUES[number]; export const Orientations: Orientation[] = [...ORIENTATION_VALUES]; export function asOrientation(input?: string): Orientation | null { if (!input) { return null; } return ORIENTATION_VALUES.includes(input) ? input as Orientation : 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_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_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'; export type FallbackType = 'customtabs' | 'webview'; type Features = { appsFlyer?: AppsFlyerConfig; locationDelegation?: LocationDelegationConfig; playBilling?: PlayBillingConfig; firstRunFlag?: FirstRunFlagConfig; arCore?: ArCoreConfig; }; type alphaDependencies = { enabled: boolean; }; /** * 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. * navigationColor: '<%= themeColor %>', // 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. * // - short_name: Shorter string used if |name| is too long. * // - url: Absolute path of the URL to launch the app with (e.g '/create'). * // - icon: Name of the resource in the drawable folder to use as an icon. * shortcuts: [ * // Insert shortcuts here, for example: * // [name: 'Open SVG', short_name: 'Open', url: '/open', icon: 'splash'] * ], * // 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. * serviceAccountJsonFile: '<%= serviceAccountJsonFile %>', // The service account used to communicate with * Google Play. * */ export class TwaManifest { packageId: string; host: string; name: string; launcherName: string; display: DisplayMode; themeColor: Color; navigationColor: Color; navigationColorDark: Color; navigationDividerColor: Color; navigationDividerColorDark: Color; backgroundColor: Color; enableNotifications: boolean; startUrl: string; iconUrl: string | undefined; maskableIconUrl: string | undefined; monochromeIconUrl: string | undefined; splashScreenFadeOutDuration: number; signingKey: SigningKeyInfo; appVersionCode: number; appVersionName: string; shortcuts: ShortcutInfo[]; generatorApp: string; webManifestUrl?: URL; fallbackType: FallbackType; features: Features; alphaDependencies: alphaDependencies; enableSiteSettingsShortcut: boolean; isChromeOSOnly: boolean; shareTarget?: ShareTarget; orientation: Orientation; fingerprints: Fingerprint[]; serviceAccountJsonFile: string | undefined; additionalTrustedOrigins: string[]; retainedBundles: number[]; private static log = new ConsoleLog('twa-manifest'); constructor(data: TwaManifestJson) { 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.navigationColor = new Color(data.navigationColor); this.navigationColorDark = new Color(data.navigationColorDark ?? DEFAULT_NAVIGATION_COLOR); this.navigationDividerColor = new Color(data.navigationDividerColor ?? DEFAULT_NAVIGATION_DIVIDER_COLOR); this.navigationDividerColorDark = new Color(data.navigationDividerColorDark ?? 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(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.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 || []; } /** * Turns an TwaManifest into a TwaManifestJson. * * @returns {TwaManifestJson} */ toJson(): TwaManifestJson { return Object.assign({}, this, { themeColor: this.themeColor.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, }); } /** * Saves the TWA Manifest to the file-system. * * @param {String} filename the location where the TWA Manifest will be saved. */ async saveToFile(filename: string): Promise<void> { const json: TwaManifestJson = 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(): string | null { let error; error = validateNotEmpty(this.host, 'host'); if (error != null) { return error; } error = validateNotEmpty(this.name, 'name'); if (error != null) { return error; } error = validateNotEmpty(this.startUrl, 'startUrl'); if (error != null) { return error; } if (!this.iconUrl) { return 'iconUrl cannot be empty'; } error = validateNotEmpty(this.iconUrl, 'iconUrl'); if (error != null) { return error; } return error; } generateShortcuts(): string { 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: URL, webManifest: WebManifestJson): TwaManifest { const icon = findSuitableIcon(webManifest.icons, 'any', MIN_ICON_SIZE); const maskableIcon = findSuitableIcon(webManifest.icons, 'maskable', MIN_ICON_SIZE); const monochromeIcon = findSuitableIcon(webManifest.icons, 'monochrome', MIN_NOTIFICATION_ICON_SIZE); const fullStartUrl: URL = new URL(webManifest['start_url'] || '/', webManifestUrl); const shortcuts: ShortcutInfo[] = this.getShortcuts(webManifestUrl, webManifest); function resolveIconUrl(icon: WebManifestIcon | null): string | undefined { return icon ? new URL(icon.src, webManifestUrl).toString() : undefined; } const twaManifest = new TwaManifest({ packageId: generatePackageId(webManifestUrl.host) || '', host: webManifestUrl.host, name: webManifest['name'] || webManifest['short_name'] || DEFAULT_APP_NAME, launcherName: webManifest['short_name'] || webManifest['name']?.substring(0, SHORT_NAME_MAX_SIZE) || DEFAULT_APP_NAME, display: asDisplayMode(webManifest['display']!) || DEFAULT_DISPLAY_MODE, themeColor: webManifest['theme_color'] || DEFAULT_THEME_COLOR, 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, }); return twaManifest; } private static verifyShareTarget( webManifestUrl: URL, shareTarget?: ShareTarget): ShareTarget | undefined { if (!shareTarget?.action) { return undefined; } if (shareTarget?.params?.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: string): Promise<TwaManifest> { const response = await fetchUtils.fetch(url); const webManifest = JSON.parse((await response.text()).trim()); const webManifestUrl: URL = 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: string): Promise<TwaManifest> { 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<T>(fieldName: string, fieldsToIgnore: string[], oldValue: T, newValue: T): T { 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: URL, webManifest: WebManifestJson): ShortcutInfo[] { const shortcuts: ShortcutInfo[] = []; for (let i = 0; i < (webManifest.shortcuts || []).length; i++) { const s = webManifest.shortcuts![i]; try { const shortcutInfo = 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: string[], fieldName: string, oldUrl: string, icons: WebManifestIcon[], iconType: string, iconSize: number, webManifestUrl: URL): string | undefined { function resolveIconUrl(icon: WebManifestIcon | null): string | undefined { return icon ? new URL(icon.src, webManifestUrl).toString() : undefined; } return (fieldsToIgnore.includes(fieldName))? oldUrl: resolveIconUrl(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: string[], webManifestUrl: URL, webManifest: WebManifestJson, oldTwaManifest: TwaManifest): Promise<TwaManifest> { let shortcuts: ShortcutInfo[] = 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 fullStartUrl: URL = new URL(webManifest['start_url'] || '/', webManifestUrl); 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'] || webManifest['name']?.substring(0, SHORT_NAME_MAX_SIZE)), display: this.getNewFieldValue('display', fieldsToIgnore, oldTwaManifest.display, asDisplayMode(webManifest['display']!)!), 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, }); return twaManifest; } } /** * A JSON representation of the TWA Manifest. Used when loading and saving the Manifest */ export interface TwaManifestJson { packageId: string; host: string; name: string; launcherName?: string; // Older Manifests may not have this field. display?: string; // Older Manifests may not have this field. themeColor: string; navigationColor: string; navigationColorDark?: string; navigationDividerColor?: string; navigationDividerColorDark?: string; backgroundColor: string; enableNotifications: boolean; startUrl: string; iconUrl?: string; maskableIconUrl?: string; monochromeIconUrl?: string; splashScreenFadeOutDuration: number; signingKey: SigningKeyInfo; appVersionCode?: number; // Older Manifests may not have this field. appVersion: string; // appVersionName - Old Manifests use `appVersion`. Keeping compatibility. shortcuts?: ShortcutInfo[]; generatorApp?: string; webManifestUrl?: string; fallbackType?: FallbackType; features?: { appsFlyer?: AppsFlyerConfig; locationDelegation?: LocationDelegationConfig; playBilling?: PlayBillingConfig; firstRunFlag?: FirstRunFlagConfig; arCore?: ArCoreConfig; }; alphaDependencies?: { enabled: boolean; }; enableSiteSettingsShortcut?: boolean; isChromeOSOnly?: boolean; shareTarget?: ShareTarget; orientation?: Orientation; fingerprints?: Fingerprint[]; serviceAccountJsonFile?: string; additionalTrustedOrigins?: string[]; retainedBundles?: number[]; } export interface SigningKeyInfo { path: string; alias: string; } export type Fingerprint = { name?: string; value: string; }