booletwa
Version:
Generate TWA projects from a Web Manifest
458 lines (394 loc) • 17.3 kB
text/typescript
/*
* 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.
*/
import * as path from 'path';
import * as fs from 'fs';
import * as Color from 'color';
import {template} from 'lodash';
import {promisify} from 'util';
import {TwaManifest} from './TwaManifest';
import {ShortcutInfo} from './ShortcutInfo';
import {Log} from './Log';
import {ImageHelper, IconDefinition} from './ImageHelper';
import {FeatureManager} from './features/FeatureManager';
import {rmdir, escapeGradleString, escapeJsonString, toAndroidScreenOrientation} from './util';
import {fetchUtils} from './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: IconDefinition[] = [
{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: IconDefinition[] = [
{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: IconDefinition[] = [
{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: IconDefinition[] = [
{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';
type ShareTargetIntentFilter = {
actions: string[];
mimeTypes: string[];
};
function shortcutMaskableTemplateFileMap(assetName: string): Record<string, string> {
return {
'app/src/main/res/drawable-anydpi-v26/shortcut_maskable.xml':
`app/src/main/res/drawable-anydpi-v26/${assetName}.xml`,
};
}
function shortcutMonochromeTemplateFileMap(assetName: string): Record<string, string> {
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: string): IconDefinition[] {
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 = promisify(fs.mkdir);
const fsCopyFile = promisify(fs.copyFile);
const fsWriteFile = promisify(fs.writeFile);
const fsReadFile = promisify(fs.readFile);
export type twaGeneratorProgress = (progress: number, total: number) => void;
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noOpProgress: twaGeneratorProgress = () => {};
/**
* An utility class to help ensure progress tracking is consistent.
*/
class Progress {
private current = 0;
constructor(private total: number, private progress: twaGeneratorProgress) {
this.progress(this.current, this.total);
}
/**
* Updates the progress. Increments current by 1.
*/
update(): void {
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(): void {
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
*/
export class TwaGenerator {
private imageHelper = new ImageHelper();
// Ensures targetDir exists and copies a file from sourceDir to target dir.
private async copyStaticFile(
sourceDir: string, targetDir: string, filename: string): Promise<void> {
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.
private copyStaticFiles(
sourceDir: string, targetDir: string, fileList: string[]): Promise<void[]> {
return Promise.all(fileList.map((file) => {
return this.copyStaticFile(sourceDir, targetDir, file);
}));
}
private async applyTemplate(
sourceFile: string, destFile: string, args: object): Promise<void> {
await fsMkDir(path.dirname(destFile), {recursive: true});
const templateFile = await fsReadFile(sourceFile, 'utf-8');
const output = template(templateFile)(args);
await fsWriteFile(destFile, output);
}
private async applyTemplateList(
sourceDir: string, targetDir: string,
fileList: string[], args: object): Promise<void> {
await Promise.all(fileList.map((filename) => {
const sourceFile = path.join(sourceDir, filename);
const destFile = path.join(targetDir, filename);
this.applyTemplate(sourceFile, destFile, args);
}));
}
private async applyJavaTemplate(
sourceDir: string, targetDir: string, packageName: string, filename: string, args: object):
Promise<void> {
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 = template(templateFile)(args);
await fsWriteFile(destFile, output);
}
private applyJavaTemplates(
sourceDir: string, targetDir: string, packageName: string, fileList: string[], args: object):
Promise<void[]> {
return Promise.all(fileList.map((file) => {
this.applyJavaTemplate(sourceDir, targetDir, packageName, file, args);
}));
}
private async applyTemplateMap(
sourceDir: string, targetDir: string,
fileMap: Record<string, string>, args: object): Promise<void> {
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);
}));
}
private async generateIcons(iconUrl: string, targetDir: string, iconList: IconDefinition[],
backgroundColor?: Color): Promise<void> {
const icon = await this.imageHelper.fetchIcon(iconUrl);
await Promise.all(iconList.map((iconDef) => {
return this.imageHelper.generateIcon(icon, targetDir, iconDef, backgroundColor);
}));
}
private async writeWebManifest(twaManifest: TwaManifest, targetDirectory: string): Promise<void> {
if (!twaManifest.webManifestUrl) {
throw new Error(
'Unable to write the Web Manifest. The TWA Manifest does not have a webManifestUrl');
}
const response = await 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.
*/
private async generateShortcuts(
targetDirectory: string, templateDirectory: string, twaManifest: TwaManifest): Promise<void> {
await Promise.all(twaManifest.shortcuts.map(async (shortcut: ShortcutInfo, i: number) => {
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);
}));
}
private static generateShareTargetIntentFilter(
twaManifest: TwaManifest): ShareTargetIntentFilter | undefined {
if (!twaManifest.shareTarget) {
return undefined;
}
const shareTargetIntentFilter: ShareTargetIntentFilter = {
actions: ['android.intent.action.SEND'],
mimeTypes: [],
};
if (twaManifest.shareTarget?.params?.url ||
twaManifest.shareTarget?.params?.title ||
twaManifest.shareTarget?.params?.text) {
shareTargetIntentFilter.mimeTypes.push('text/plain');
}
if (twaManifest.shareTarget?.params?.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: string, twaManifest: TwaManifest, log: Log,
reportProgress: twaGeneratorProgress = noOpProgress): Promise<void> {
const features = new 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: escapeJsonString,
escapeGradleString: escapeGradleString,
toAndroidScreenOrientation: 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: string): Promise<void> {
await Promise.all(
DELETE_PROJECT_FILE_LIST.map((entry) => rmdir(path.join(targetDirectory, entry))));
}
}