@bubblewrap/core
Version:
Core Library to generate, build and sign TWA projects
365 lines (364 loc) • 17.5 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.TwaGenerator = void 0;
const path = require("path");
const fs = require("fs");
const lodash_1 = require("lodash");
const util_1 = require("util");
const ImageHelper_1 = require("./ImageHelper");
const FeatureManager_1 = require("./features/FeatureManager");
const util_2 = require("./util");
const FetchUtils_1 = require("./FetchUtils");
const COPY_FILE_LIST = [
'settings.gradle',
'gradle.properties',
'build.gradle',
'gradlew',
'gradlew.bat',
'gradle/wrapper/gradle-wrapper.jar',
'gradle/wrapper/gradle-wrapper.properties',
'app/src/main/res/values/colors.xml',
'app/src/main/res/xml/filepaths.xml',
'app/src/main/res/xml/shortcuts.xml',
'app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml',
'app/src/main/res/drawable-anydpi/shortcut_legacy_background.xml',
];
const TEMPLATE_FILE_LIST = [
'app/build.gradle',
'app/src/main/AndroidManifest.xml',
'app/src/main/res/values/strings.xml',
];
const JAVA_DIR = 'app/src/main/java/';
const JAVA_FILE_LIST = [
'LauncherActivity.java',
'Application.java',
'DelegationService.java',
];
const DELETE_PROJECT_FILE_LIST = [
'settings.gradle',
'gradle.properties',
'build.gradle',
'gradlew',
'gradlew.bat',
'store_icon.png',
'gradle/',
'app/',
];
const DELETE_FILE_LIST = [
'app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml',
];
const SPLASH_IMAGES = [
{ dest: 'app/src/main/res/drawable-mdpi/splash.png', size: 300 },
{ dest: 'app/src/main/res/drawable-hdpi/splash.png', size: 450 },
{ dest: 'app/src/main/res/drawable-xhdpi/splash.png', size: 600 },
{ dest: 'app/src/main/res/drawable-xxhdpi/splash.png', size: 900 },
{ dest: 'app/src/main/res/drawable-xxxhdpi/splash.png', size: 1200 },
];
const IMAGES = [
{ dest: 'app/src/main/res/mipmap-mdpi/ic_launcher.png', size: 48 },
{ dest: 'app/src/main/res/mipmap-hdpi/ic_launcher.png', size: 72 },
{ dest: 'app/src/main/res/mipmap-xhdpi/ic_launcher.png', size: 96 },
{ dest: 'app/src/main/res/mipmap-xxhdpi/ic_launcher.png', size: 144 },
{ dest: 'app/src/main/res/mipmap-xxxhdpi/ic_launcher.png', size: 192 },
{ dest: 'store_icon.png', size: 512 },
];
const ADAPTIVE_IMAGES = [
{ dest: 'app/src/main/res/mipmap-mdpi/ic_maskable.png', size: 82 },
{ dest: 'app/src/main/res/mipmap-hdpi/ic_maskable.png', size: 123 },
{ dest: 'app/src/main/res/mipmap-xhdpi/ic_maskable.png', size: 164 },
{ dest: 'app/src/main/res/mipmap-xxhdpi/ic_maskable.png', size: 246 },
{ dest: 'app/src/main/res/mipmap-xxxhdpi/ic_maskable.png', size: 328 },
];
const NOTIFICATION_IMAGES = [
{ dest: 'app/src/main/res/drawable-mdpi/ic_notification_icon.png', size: 24 },
{ dest: 'app/src/main/res/drawable-hdpi/ic_notification_icon.png', size: 36 },
{ dest: 'app/src/main/res/drawable-xhdpi/ic_notification_icon.png', size: 48 },
{ dest: 'app/src/main/res/drawable-xxhdpi/ic_notification_icon.png', size: 72 },
{ dest: 'app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png', size: 96 },
];
const WEB_MANIFEST_LOCATION = '/app/src/main/res/raw/';
const WEB_MANIFEST_FILE_NAME = 'web_app_manifest.json';
function shortcutMaskableTemplateFileMap(assetName) {
return {
'app/src/main/res/drawable-anydpi-v26/shortcut_maskable.xml': `app/src/main/res/drawable-anydpi-v26/${assetName}.xml`,
};
}
function shortcutMonochromeTemplateFileMap(assetName) {
return {
'app/src/main/res/drawable-anydpi/shortcut_monochrome.xml': `app/src/main/res/drawable-anydpi/${assetName}.xml`,
'app/src/main/res/drawable-anydpi-v26/shortcut_monochrome.xml': `app/src/main/res/drawable-anydpi-v26/${assetName}.xml`,
};
}
function shortcutImages(assetName) {
return [
{ dest: `app/src/main/res/drawable-mdpi/${assetName}.png`, size: 48 },
{ dest: `app/src/main/res/drawable-hdpi/${assetName}.png`, size: 72 },
{ dest: `app/src/main/res/drawable-xhdpi/${assetName}.png`, size: 96 },
{ dest: `app/src/main/res/drawable-xxhdpi/${assetName}.png`, size: 144 },
{ dest: `app/src/main/res/drawable-xxxhdpi/${assetName}.png`, size: 192 },
];
}
// fs.promises is marked as experimental. This should be replaced when stable.
const fsMkDir = (0, util_1.promisify)(fs.mkdir);
const fsCopyFile = (0, util_1.promisify)(fs.copyFile);
const fsWriteFile = (0, util_1.promisify)(fs.writeFile);
const fsReadFile = (0, util_1.promisify)(fs.readFile);
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noOpProgress = () => { };
/**
* An utility class to help ensure progress tracking is consistent.
*/
class Progress {
constructor(total, progress) {
this.total = total;
this.progress = progress;
this.current = 0;
this.progress(this.current, this.total);
}
/**
* Updates the progress. Increments current by 1.
*/
update() {
if (this.current === this.total) {
throw new Error('Progress already reached total.' +
` current: ${this.current}, total: ${this.total}`);
}
this.current++;
this.progress(this.current, this.total);
}
/**
* Should be called for the last update. Throws an error if total !== current after incrementing
* current.
*/
done() {
this.update();
if (this.current !== this.total) {
throw new Error('Invoked done before current equals total.' +
` current: ${this.current}, total: ${this.total}`);
}
}
}
/**
* Generates TWA Projects from a TWA Manifest
*/
class TwaGenerator {
constructor() {
this.imageHelper = new ImageHelper_1.ImageHelper();
}
// Ensures targetDir exists and copies a file from sourceDir to target dir.
async copyStaticFile(sourceDir, targetDir, filename) {
const sourceFile = path.join(sourceDir, filename);
const destFile = path.join(targetDir, filename);
await fsMkDir(path.dirname(destFile), { recursive: true });
await fsCopyFile(sourceFile, destFile);
}
// Copies a list of file from sourceDir to targetDir.
copyStaticFiles(sourceDir, targetDir, fileList) {
return Promise.all(fileList.map((file) => {
return this.copyStaticFile(sourceDir, targetDir, file);
}));
}
async applyTemplate(sourceFile, destFile, args) {
await fsMkDir(path.dirname(destFile), { recursive: true });
const templateFile = await fsReadFile(sourceFile, 'utf-8');
const output = (0, lodash_1.template)(templateFile)(args);
await fsWriteFile(destFile, output);
}
async applyTemplateList(sourceDir, targetDir, fileList, args) {
await Promise.all(fileList.map((filename) => {
const sourceFile = path.join(sourceDir, filename);
const destFile = path.join(targetDir, filename);
this.applyTemplate(sourceFile, destFile, args);
}));
}
async applyJavaTemplate(sourceDir, targetDir, packageName, filename, args) {
const sourceFile = path.join(sourceDir, JAVA_DIR, filename);
const destFile = path.join(targetDir, JAVA_DIR, packageName.split('.').join('/'), filename);
await fsMkDir(path.dirname(destFile), { recursive: true });
const templateFile = await fsReadFile(sourceFile, 'utf-8');
const output = (0, lodash_1.template)(templateFile)(args);
await fsWriteFile(destFile, output);
}
applyJavaTemplates(sourceDir, targetDir, packageName, fileList, args) {
return Promise.all(fileList.map((file) => {
this.applyJavaTemplate(sourceDir, targetDir, packageName, file, args);
}));
}
async applyTemplateMap(sourceDir, targetDir, fileMap, args) {
await Promise.all(Object.keys(fileMap).map((filename) => {
const sourceFile = path.join(sourceDir, filename);
const destFile = path.join(targetDir, fileMap[filename]);
this.applyTemplate(sourceFile, destFile, args);
}));
}
async generateIcons(iconUrl, targetDir, iconList, backgroundColor) {
const icon = await this.imageHelper.fetchIcon(iconUrl);
await Promise.all(iconList.map((iconDef) => {
return this.imageHelper.generateIcon(icon, targetDir, iconDef, backgroundColor);
}));
}
async writeWebManifest(twaManifest, targetDirectory) {
if (!twaManifest.webManifestUrl) {
throw new Error('Unable to write the Web Manifest. The TWA Manifest does not have a webManifestUrl');
}
const response = await FetchUtils_1.fetchUtils.fetch(twaManifest.webManifestUrl.toString());
if (response.status !== 200) {
throw new Error(`Failed to download Web Manifest ${twaManifest.webManifestUrl}.` +
`Responded with status ${response.status}`);
}
// We're writing as a string, but attempt to convert to check if it's a well-formed JSON.
const webManifestJson = JSON.parse((await response.text()).trim());
// We want to ensure that "start_url" is the same used to launch the Trusted Web Activity.
webManifestJson['start_url'] = twaManifest.startUrl;
const webManifestLocation = path.join(targetDirectory, WEB_MANIFEST_LOCATION);
// Ensures the target directory exists.
await fs.promises.mkdir(webManifestLocation, { recursive: true });
const webManifestFileName = path.join(webManifestLocation, WEB_MANIFEST_FILE_NAME);
await fs.promises.writeFile(webManifestFileName, JSON.stringify(webManifestJson));
}
/**
* Generates shortcut data for a new TWA Project.
*
* @param {String} targetDirectory the directory where the project will be created
* @param {String} templateDirectory the directory where templates are located.
* @param {Object} twaManifest configurations values for the project.
*/
async generateShortcuts(targetDirectory, templateDirectory, twaManifest) {
await Promise.all(twaManifest.shortcuts.map(async (shortcut, i) => {
const assetName = shortcut.assetName(i);
const monochromeAssetName = `${assetName}_monochrome`;
const maskableAssetName = `${assetName}_maskable`;
const templateArgs = { assetName, monochromeAssetName, maskableAssetName };
if (shortcut.chosenMonochromeIconUrl) {
await this.applyTemplateMap(templateDirectory, targetDirectory, shortcutMonochromeTemplateFileMap(assetName), templateArgs);
const monochromeImages = shortcutImages(monochromeAssetName);
const baseMonochromeIcon = await this.imageHelper.fetchIcon(shortcut.chosenMonochromeIconUrl);
const monochromeIcon = await this.imageHelper.monochromeFilter(baseMonochromeIcon, twaManifest.themeColor);
return await Promise.all(monochromeImages.map((iconDef) => {
return this.imageHelper.generateIcon(monochromeIcon, targetDirectory, iconDef);
}));
}
if (!shortcut.chosenIconUrl) {
throw new Error(`ShortcutInfo ${shortcut.name} is missing chosenIconUrl and chosenMonochromeIconUrl`);
}
if (shortcut.chosenMaskableIconUrl) {
await this.applyTemplateMap(templateDirectory, targetDirectory, shortcutMaskableTemplateFileMap(assetName), templateArgs);
const maskableImages = shortcutImages(maskableAssetName);
await this.generateIcons(shortcut.chosenMaskableIconUrl, targetDirectory, maskableImages);
}
const images = shortcutImages(assetName);
return this.generateIcons(shortcut.chosenIconUrl, targetDirectory, images);
}));
}
static generateShareTargetIntentFilter(twaManifest) {
var _a, _b, _c, _d, _e, _f, _g, _h;
if (!twaManifest.shareTarget) {
return undefined;
}
const shareTargetIntentFilter = {
actions: ['android.intent.action.SEND'],
mimeTypes: [],
};
if (((_b = (_a = twaManifest.shareTarget) === null || _a === void 0 ? void 0 : _a.params) === null || _b === void 0 ? void 0 : _b.url) ||
((_d = (_c = twaManifest.shareTarget) === null || _c === void 0 ? void 0 : _c.params) === null || _d === void 0 ? void 0 : _d.title) ||
((_f = (_e = twaManifest.shareTarget) === null || _e === void 0 ? void 0 : _e.params) === null || _f === void 0 ? void 0 : _f.text)) {
shareTargetIntentFilter.mimeTypes.push('text/plain');
}
if ((_h = (_g = twaManifest.shareTarget) === null || _g === void 0 ? void 0 : _g.params) === null || _h === void 0 ? void 0 : _h.files) {
shareTargetIntentFilter.actions.push('android.intent.action.SEND_MULTIPLE');
for (const file of twaManifest.shareTarget.params.files) {
file.accept.forEach((accept) => shareTargetIntentFilter.mimeTypes.push(accept));
}
}
return shareTargetIntentFilter;
}
/**
* Creates a new TWA Project.
*
* @param {String} targetDirectory the directory where the project will be created
* @param {Object} twaManifest configurations values for the project.
*/
async createTwaProject(targetDirectory, twaManifest, log, reportProgress = noOpProgress) {
const features = new FeatureManager_1.FeatureManager(twaManifest, log);
const progress = new Progress(9, reportProgress);
const error = twaManifest.validate();
if (error !== null) {
throw new Error(`Invalid TWA Manifest: ${error}`);
}
const templateDirectory = path.join(__dirname, '../../template_project');
const copyFileList = new Set(COPY_FILE_LIST);
if (!twaManifest.maskableIconUrl) {
DELETE_FILE_LIST.forEach((file) => copyFileList.delete(file));
}
progress.update();
// Copy Project Files
await this.copyStaticFiles(templateDirectory, targetDirectory, Array.from(copyFileList));
// Apply proper permissions to gradlew. See https://nodejs.org/api/fs.html#fs_file_modes
await fs.promises.chmod(path.join(targetDirectory, 'gradlew'), '755');
progress.update();
// Those are the arguments passed when applying templates. Functions are not automatically
// copied from objects, so we explicitly copy generateShortcuts.
const args = {
...twaManifest,
...features,
shareTargetIntentFilter: TwaGenerator.generateShareTargetIntentFilter(twaManifest),
generateShortcuts: twaManifest.generateShortcuts,
escapeJsonString: util_2.escapeJsonString,
escapeGradleString: util_2.escapeGradleString,
toAndroidScreenOrientation: util_2.toAndroidScreenOrientation,
};
// Generate templated files
await this.applyTemplateList(templateDirectory, targetDirectory, TEMPLATE_FILE_LIST, args);
progress.update();
// Generate java files
await this.applyJavaTemplates(templateDirectory, targetDirectory, twaManifest.packageId, JAVA_FILE_LIST, args);
progress.update();
// Generate images
if (twaManifest.iconUrl) {
await this.generateIcons(twaManifest.iconUrl, targetDirectory, IMAGES);
await this.generateIcons(twaManifest.iconUrl, targetDirectory, SPLASH_IMAGES, twaManifest.backgroundColor);
}
progress.update();
await this.generateShortcuts(targetDirectory, templateDirectory, twaManifest);
progress.update();
// Generate adaptive images
if (twaManifest.maskableIconUrl) {
await this.generateIcons(twaManifest.maskableIconUrl, targetDirectory, ADAPTIVE_IMAGES);
}
progress.update();
// Generate notification images
const iconOrMonochromeIconUrl = twaManifest.monochromeIconUrl || twaManifest.iconUrl;
if (twaManifest.enableNotifications && iconOrMonochromeIconUrl) {
await this.generateIcons(iconOrMonochromeIconUrl, targetDirectory, NOTIFICATION_IMAGES);
}
progress.update();
if (twaManifest.webManifestUrl) {
// Save the Web Manifest into the project
await this.writeWebManifest(twaManifest, targetDirectory);
}
progress.done();
}
/**
* Removes all files generated by crateTwaProject.
* @param targetDirectory the directory where the project was created.
*/
async removeTwaProject(targetDirectory) {
await Promise.all(DELETE_PROJECT_FILE_LIST.map((entry) => (0, util_2.rmdir)(path.join(targetDirectory, entry))));
}
}
exports.TwaGenerator = TwaGenerator;