Files
flipper/desktop/scripts/build-release.ts
Michel Weststrate 2b2cbb1103 Move offline icon storage to app/
Summary:
This diff splits Facebook management into a ui-core and electron part:

* Cleaned up code and introduces a uniform Icon type to describe a requested icon
* Computing icon urls is done in the ui-core
* Introduced a RenderHost hook that can transform the request icon into a different url, in this case, a url to load icons from disk in production builds

For the browser UI, the urls are currently no rewritten since we have only dev builds (which always used only FB urls already). We could do the same rewrite in the future and download the static assets during build time. But for now this means that in the browser implementation we depend on normal browser caching, with the biggest downside that icons might not appear if the user has no internet connections.

With this change we lost our last usage of staticPath computations in ui-core

Reviewed By: aigoncharov

Differential Revision: D32767426

fbshipit-source-id: d573b6a373e649c7dacd380cf63a50c2dbbd9e70
2021-12-08 04:30:57 -08:00

394 lines
12 KiB
TypeScript
Executable File

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import path from 'path';
import fs from 'fs-extra';
import {
Platform,
Arch,
ElectronDownloadOptions,
build,
AfterPackContext,
AppInfo,
} from 'electron-builder';
import {spawn} from 'promisify-child-process';
import {
buildFolder,
compileRenderer,
compileMain,
die,
getVersionNumber,
genMercurialRevision,
prepareDefaultPlugins,
moveSourceMaps,
} from './build-utils';
import fetch from '@adobe/node-fetch-retry';
import {buildLocalIconPath, Icons} from '../app/src/utils/icons';
import isFB from './isFB';
import copyPackageWithDependencies from './copy-package-with-dependencies';
import {staticDir, distDir} from './paths';
import yargs from 'yargs';
import {WinPackager} from 'app-builder-lib/out/winPackager';
import {Icon, getPublicIconUrl} from 'flipper-ui-core/src/utils/icons';
// Used in some places to avoid release-to-release changes. Needs
// to be this high for some MacOS-specific things that I can't
// remember right now.
const FIX_RELEASE_VERSION = '50.0.0';
const argv = yargs
.usage('yarn build [args]')
.version(false)
.options({
mac: {
type: 'boolean',
group: 'targets',
},
'mac-dmg': {
type: 'boolean',
group: 'targets',
},
win: {
type: 'boolean',
group: 'targets',
},
linux: {
type: 'boolean',
group: 'targets',
},
'linux-deb': {
type: 'boolean',
group: 'targets',
},
'linux-snap': {
type: 'boolean',
group: 'targets',
},
version: {
description:
'Unique build identifier to be used as the version patch part for the build',
type: 'number',
},
channel: {
description: 'Release channel for the build',
choices: ['stable', 'insiders'],
default: 'stable',
},
'bundled-plugins': {
describe:
'Enables bundling of plugins into Flipper bundle. Env var FLIPPER_NO_BUNDLED_PLUGINS is equivalent to the command-line option "--no-bundled-plugins".',
type: 'boolean',
},
'rebuild-plugins': {
describe:
'Enables rebuilding of default plugins on Flipper build. Only make sense in conjunction with "--no-bundled-plugins". Enabled by default, but if disabled using "--no-plugin-rebuild", then plugins are just released as is without rebuilding. This can save some time if you know plugin bundles are already up-to-date.',
type: 'boolean',
},
'default-plugins-dir': {
describe:
'Directory with prepared list of default plugins which will be included into the Flipper distribution as "defaultPlugins" dir',
type: 'string',
},
'source-map-dir': {
describe:
'Directory to write the main.bundle.map and bundle.map files for the main and render bundle sourcemaps, respectively',
type: 'string',
},
})
.help()
.strict()
.check((argv) => {
const targetSpecified =
argv.mac ||
argv['mac-dmg'] ||
argv.win ||
argv.linux ||
argv['linux-deb'] ||
argv['linux-snap'];
if (!targetSpecified) {
throw new Error('No targets specified. eg. --mac, --win, or --linux');
}
return true;
})
.parse(process.argv.slice(1));
if (isFB) {
process.env.FLIPPER_FB = 'true';
}
process.env.FLIPPER_RELEASE_CHANNEL = argv.channel;
if (argv['bundled-plugins'] === false) {
process.env.FLIPPER_NO_BUNDLED_PLUGINS = 'true';
} else if (argv['bundled-plugins'] === true) {
delete process.env.FLIPPER_NO_BUNDLED_PLUGINS;
}
if (argv['rebuild-plugins'] === false) {
process.env.FLIPPER_NO_REBUILD_PLUGINS = 'true';
} else if (argv['rebuild-plugins'] === true) {
delete process.env.FLIPPER_NO_REBUILD_PLUGINS;
}
if (argv['default-plugins-dir']) {
process.env.FLIPPER_DEFAULT_PLUGINS_DIR = argv['default-plugins-dir'];
}
async function generateManifest(versionNumber: string) {
await fs.writeFile(
path.join(distDir, 'manifest.json'),
JSON.stringify({
package: 'com.facebook.sonar',
version_name: versionNumber,
}),
);
}
async function modifyPackageManifest(
buildFolder: string,
versionNumber: string,
hgRevision: string | null,
channel: string,
) {
// eslint-disable-next-line no-console
console.log('Creating package.json manifest');
const manifest = require('../package.json');
const manifestStatic = require('../static/package.json');
// The manifest's dependencies are bundled with the final app by
// electron-builder. We want to bundle the dependencies from the static-folder
// because all dependencies from the root-folder are already bundled by metro.
manifest.dependencies = manifestStatic.dependencies;
manifest.main = 'index.js';
manifest.version = versionNumber;
if (hgRevision != null) {
manifest.revision = hgRevision;
}
manifest.releaseChannel = channel;
await fs.writeFile(
path.join(buildFolder, 'package.json'),
JSON.stringify(manifest, null, ' '),
);
}
// Same as for MacOS, we are hardcoding version information and other
// properties on Windows that change from release to release to improve cache
// behaviour. This is especially important as the .exe contains the Electron/Chromium
// frameworks which are > 120 MB in size.
// Note: This is run *after* packing has completed, meaning that ZIP file will
// not include these changes. As our packer operates on the unpacked results,
// this doesn't matter.
async function afterPack(context: AfterPackContext) {
if (context.electronPlatformName !== 'win32' || !isFB) {
return;
}
// Because all of this is implemented in an OOP way,
// we're having to do a lot of hacky shit here to
// temporarily override properties. While it may look
// cleaner to just have a big ts-ignore block, by
// only disabling `readonly` flags, we at least
// get remaining guarantees regarding type alignment
// and property names being present.
type Mutable<T> = {-readonly [P in keyof T]: T[P]};
const originalPackager = Object.assign({}, context.packager);
const packager = context.packager as unknown as WinPackager;
const appInfo: Mutable<AppInfo> = packager.appInfo;
const exeFileName = `${packager.appInfo.productFilename}.exe`;
appInfo.version = FIX_RELEASE_VERSION;
appInfo.buildVersion = FIX_RELEASE_VERSION;
appInfo.shortVersion = FIX_RELEASE_VERSION;
// Contains a side-effect dependent on the current year.
Object.defineProperty(appInfo, 'copyright', {
get: () => 'Facebook, Inc.',
});
packager.signAndEditResources(
path.join(context.appOutDir, exeFileName),
context.arch,
context.outDir,
path.basename(exeFileName, '.exe'),
packager.platformSpecificBuildOptions.requestedExecutionLevel,
);
(context as Mutable<AfterPackContext>).packager = originalPackager;
}
async function buildDist(buildFolder: string) {
const targetsRaw: Map<Platform, Map<Arch, string[]>>[] = [];
const postBuildCallbacks: (() => void)[] = [];
if (argv.mac || argv['mac-dmg']) {
targetsRaw.push(Platform.MAC.createTarget(['dir']));
// You can build mac apps on Linux but can't build dmgs, so we separate those.
if (argv['mac-dmg']) {
targetsRaw.push(Platform.MAC.createTarget(['dmg']));
}
postBuildCallbacks.push(() =>
spawn('zip', ['-qyr9', '../Flipper-mac.zip', 'Flipper.app'], {
cwd: path.join(distDir, 'mac'),
encoding: 'utf-8',
}),
);
}
if (argv.linux || argv['linux-deb'] || argv['linux-snap']) {
targetsRaw.push(Platform.LINUX.createTarget(['zip']));
if (argv['linux-deb']) {
// linux targets can be:
// AppImage, snap, deb, rpm, freebsd, pacman, p5p, apk, 7z, zip, tar.xz, tar.lz, tar.gz, tar.bz2, dir
targetsRaw.push(Platform.LINUX.createTarget(['deb']));
}
if (argv['linux-snap']) {
targetsRaw.push(Platform.LINUX.createTarget(['snap']));
}
}
if (argv.win) {
targetsRaw.push(Platform.WINDOWS.createTarget(['zip']));
}
if (!targetsRaw.length) {
throw new Error('No targets specified. eg. --mac, --win, or --linux');
}
// merge all target maps into a single map
let targetsMerged: [Platform, Map<Arch, string[]>][] = [];
for (const target of targetsRaw) {
targetsMerged = targetsMerged.concat(Array.from(target));
}
const targets = new Map(targetsMerged);
const electronDownloadOptions: ElectronDownloadOptions = {};
if (process.env.electron_config_cache) {
electronDownloadOptions.cache = process.env.electron_config_cache;
}
try {
await build({
publish: 'never',
config: {
appId: `com.facebook.sonar`,
productName: 'Flipper',
directories: {
buildResources: buildFolder,
output: distDir,
},
electronDownload: electronDownloadOptions,
npmRebuild: false,
linux: {
executableName: 'flipper',
},
mac: {
bundleVersion: FIX_RELEASE_VERSION,
},
win: {
signAndEditExecutable: !isFB,
},
afterPack,
},
projectDir: buildFolder,
targets,
});
return await Promise.all(postBuildCallbacks.map((p) => p()));
} catch (err) {
return die(err);
}
}
async function copyStaticFolder(buildFolder: string) {
console.log(`⚙️ Copying static package with dependencies...`);
await copyPackageWithDependencies(staticDir, buildFolder);
console.log('✅ Copied static package with dependencies.');
}
// Takes a string like 'star', or 'star-outline', and converts it to
// {trimmedName: 'star', variant: 'filled'} or {trimmedName: 'star', variant: 'outline'}
function getIconPartsFromName(icon: string): {
trimmedName: string;
variant: 'outline' | 'filled';
} {
const isOutlineVersion = icon.endsWith('-outline');
const trimmedName = isOutlineVersion ? icon.replace('-outline', '') : icon;
const variant = isOutlineVersion ? 'outline' : 'filled';
return {trimmedName: trimmedName, variant: variant};
}
async function downloadIcons(buildFolder: string) {
const icons: Icons = JSON.parse(
await fs.promises.readFile(path.join(buildFolder, 'icons.json'), {
encoding: 'utf8',
}),
);
const iconURLs = Object.entries(icons).reduce<Icon[]>(
(acc, [entryName, sizes]) => {
const {trimmedName: name, variant} = getIconPartsFromName(entryName);
acc.push(
// get icons in @1x and @2x
...sizes.map((size) => ({name, variant, size, density: 1})),
...sizes.map((size) => ({name, variant, size, density: 2})),
);
return acc;
},
[],
);
return Promise.all(
iconURLs.map((icon) => {
const url = getPublicIconUrl(icon);
return fetch(url, {
retryOptions: {
// Be default, only 5xx are retried but we're getting the odd 404
// which goes away on a retry for some reason.
retryOnHttpResponse: (res) => res.status >= 400,
},
})
.then((res) => {
if (res.status !== 200) {
throw new Error(
// eslint-disable-next-line prettier/prettier
`Could not download the icon ${name} from ${url}: got status ${res.status}`,
);
}
return res;
})
.then(
(res) =>
new Promise((resolve, reject) => {
const fileStream = fs.createWriteStream(
path.join(buildFolder, buildLocalIconPath(icon)),
);
res.body.pipe(fileStream);
res.body.on('error', reject);
fileStream.on('finish', resolve);
}),
);
}),
);
}
(async () => {
const dir = await buildFolder();
// eslint-disable-next-line no-console
console.log('Created build directory', dir);
await compileMain();
await prepareDefaultPlugins(argv.channel === 'insiders');
await copyStaticFolder(dir);
await downloadIcons(dir);
await compileRenderer(dir);
await moveSourceMaps(dir, argv['source-map-dir']);
const versionNumber = getVersionNumber(argv.version);
const hgRevision = await genMercurialRevision();
await modifyPackageManifest(dir, versionNumber, hgRevision, argv.channel);
await fs.ensureDir(distDir);
await generateManifest(versionNumber);
await buildDist(dir);
// eslint-disable-next-line no-console
console.log('✨ Done');
process.exit();
})();