@bubblewrap/core
Version:
Core Library to generate, build and sign TWA projects
437 lines (436 loc) • 23.3 kB
JavaScript
/*
* 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');