react-native-bootsplash
Version:
Display a bootsplash on your app starts. Hide it when you want.
846 lines (738 loc) • 23.8 kB
text/typescript
import * as Expo from "@expo/config-plugins";
import crypto from "crypto";
import detectIndent, { type Indent } from "detect-indent";
import fs from "fs";
import { parse as parseHtml } from "node-html-parser";
import path from "path";
import pc from "picocolors";
import type { Options as PrettierOptions } from "prettier";
import * as htmlPlugin from "prettier/plugins/html";
import * as cssPlugin from "prettier/plugins/postcss";
import * as prettier from "prettier/standalone";
import semver from "semver";
import sharp, { type Sharp } from "sharp";
import { dedent } from "ts-dedent";
import formatXml, { type XMLFormatterOptions } from "xml-formatter";
import type { Manifest } from "..";
export const PACKAGE_NAME = "react-native-bootsplash";
export type BootSplashPluginConfig = {
/**
* Logo file path (PNG or SVG)
*/
logo: string;
/**
* Background color (in hexadecimal format)
* @default "#fff"
*/
background?: string;
/**
* Logo width at @1x (in dp - we recommend approximately ~100)
* @default 100
*/
logoWidth?: number;
/**
* Assets output directory path
* @default "assets/bootsplash"
*/
assetsOutput?: string;
/**
* License key to enable brand and dark mode assets generation
* @deprecated Passing `licenseKey` directly in plugin config is deprecated as it could be leaked by `expo-constants`. Use the `BOOTSPLASH_LICENSE_KEY` environment variable instead.
*/
licenseKey?: string;
/**
* Brand file path (PNG or SVG)
*/
brand?: string;
/**
* Brand width at @1x (in dp - we recommend approximately ~80)
* @default 80
*/
brandWidth?: number;
/**
* [dark mode] Background color (in hexadecimal format)
*/
darkBackground?: string;
/**
* [dark mode] Logo file path (PNG or SVG)
*/
darkLogo?: string;
/**
* [dark mode] Brand file path (PNG or SVG)
*/
darkBrand?: string;
android?: {
/**
* Enforce system bars style
*/
darkContentBarsStyle?: boolean;
};
};
type LoggerMode = { type: "plugin" } | { type: "cli"; cwd: string };
let loggerMode: LoggerMode = { type: "plugin" };
export const setLoggerMode = (value: LoggerMode) => {
loggerMode = value;
};
const warningMessages = new Set<string>();
const errorMessages = new Set<string>();
export const log = {
warn: (text: string) => {
if (loggerMode.type === "cli") {
console.log(pc.yellow(`⚠️ ${text}`));
} else {
const message = `⚠️ [${PACKAGE_NAME}] ${text}`;
if (!warningMessages.has(message)) {
warningMessages.add(message);
console.log(pc.yellow(message));
}
}
},
error: (text: string) => {
if (loggerMode.type === "cli") {
console.log(pc.red(`❌ ${text}`));
} else {
const message = `❌ [${PACKAGE_NAME}] ${text}`;
if (!errorMessages.has(message)) {
errorMessages.add(message);
console.log(pc.red(message));
}
}
},
title: (emoji: string, text: string) => {
if (loggerMode.type === "cli") {
console.log(`\n${emoji} ${pc.underline(pc.bold(text))}`);
}
},
write: (filePath: string, dimensions?: { width: number; height: number }) => {
if (loggerMode.type === "cli") {
console.log(
` ${path.relative(loggerMode.cwd, filePath)}` +
(dimensions != null
? ` (${dimensions.width}x${dimensions.height})`
: ""),
);
}
},
};
// Freely inspired by https://github.com/humanwhocodes/humanfs
export const hfs = {
buffer: (path: string) => fs.readFileSync(path),
exists: (path: string) => fs.existsSync(path),
json: (path: string) => JSON.parse(fs.readFileSync(path, "utf-8")) as unknown,
readDir: (path: string) => fs.readdirSync(path, "utf-8"),
realPath: (path: string) => fs.realpathSync(path, "utf-8"),
rm: (path: string) => fs.rmSync(path, { force: true, recursive: true }),
text: (path: string) => fs.readFileSync(path, "utf-8"),
ensureDir: (dir: string) => {
fs.mkdirSync(dir, { recursive: true });
},
write: (path: string, content: string) => {
const trimmed = content.trim();
fs.writeFileSync(path, trimmed === "" ? trimmed : trimmed + "\n", "utf-8");
},
};
export const writeJson = (filePath: string, content: object) => {
hfs.write(filePath, JSON.stringify(content, null, 2));
log.write(filePath);
};
type FormatOptions = { indent?: Indent } & (
| {
formatter: "prettier";
selfClosingTags?: boolean;
useCssPlugin?: boolean;
htmlWhitespaceSensitivity?: PrettierOptions["htmlWhitespaceSensitivity"];
singleAttributePerLine?: PrettierOptions["singleAttributePerLine"];
}
| {
formatter: "xmlFormatter";
whiteSpaceAtEndOfSelfclosingTag?: XMLFormatterOptions["whiteSpaceAtEndOfSelfclosingTag"];
}
);
export const readXmlLike = (filePath: string) => {
const content = hfs.text(filePath);
return {
root: parseHtml(content),
formatOptions: { indent: detectIndent(content) },
};
};
export const writeXmlLike = async (
filePath: string,
content: string,
{ indent, ...formatOptions }: FormatOptions,
) => {
if (formatOptions.formatter === "prettier") {
const {
formatter,
useCssPlugin = false,
selfClosingTags = false,
...options
} = formatOptions;
const formatted = await prettier.format(content, {
parser: "html",
bracketSameLine: true,
printWidth: 10000,
plugins: [htmlPlugin, ...(useCssPlugin ? [cssPlugin] : [])],
useTabs: indent?.type === "tab",
tabWidth: (indent?.amount ?? 0) || 2,
...options,
});
hfs.write(
filePath,
selfClosingTags
? formatted.replace(/><\/[a-z-0-9]+>/gi, " />")
: formatted,
);
log.write(filePath);
} else {
const { formatter, ...options } = formatOptions;
const formatted = formatXml(content, {
collapseContent: true,
forceSelfClosingEmptyTag: true,
lineSeparator: "\n",
whiteSpaceAtEndOfSelfclosingTag: true,
indentation: (indent?.indent ?? "") || " ",
...options,
});
hfs.write(filePath, formatted);
log.write(filePath);
}
};
export type Asset = {
path: string;
image: Sharp;
hash: string;
height: number;
width: number;
};
const toAsset = async (filePath: string, width: number): Promise<Asset> => {
const image = sharp(filePath);
const { format } = await image.metadata();
if (format !== "png" && format !== "svg") {
log.error(
`${path.basename(filePath)} image file format (${format}) is not supported`,
);
process.exit(1);
}
const [height, hash] = await Promise.all([
image
.clone()
.resize(width)
.toBuffer()
.then((buffer) => sharp(buffer).metadata())
.then(({ height = 0 }) => Math.round(height)),
image
.clone()
.resize(width)
.png({ quality: 100 })
.toBuffer()
.then((buffer) => buffer.toString("base64")),
]);
return {
path: filePath,
image,
hash,
height,
width,
};
};
const parseColor = (value: string) => {
const up = value.toUpperCase().replace(/[^0-9A-F]/g, "");
if (up.length !== 3 && up.length !== 6) {
log.error(`"${value}" value is not a valid hexadecimal color.`);
process.exit(1);
}
const hex =
up.length === 3
? "#" + up[0] + up[0] + up[1] + up[1] + up[2] + up[2]
: "#" + up;
const rgb = {
R: (Number.parseInt("" + hex[1] + hex[2], 16) / 255).toPrecision(15),
G: (Number.parseInt("" + hex[3] + hex[4], 16) / 255).toPrecision(15),
B: (Number.parseInt("" + hex[5] + hex[6], 16) / 255).toPrecision(15),
};
return { hex: hex.toLowerCase(), rgb };
};
export const transformProps = async (
rootPath: string,
{ android = {}, licenseKey, ...rawProps }: BootSplashPluginConfig,
) => {
if (semver.lt(process.versions.node, "20.0.0")) {
log.error("Requires Node 20 (or higher)");
process.exit(1);
}
const withDefaults = {
assetsOutput: "assets/bootsplash",
background: "#fff",
brandWidth: 80,
logoWidth: 100,
...rawProps,
};
const assetsOutputPath = path.resolve(rootPath, withDefaults.assetsOutput);
const logoPath = path.resolve(rootPath, withDefaults.logo);
const darkLogoPath =
withDefaults.darkLogo != null
? path.resolve(rootPath, withDefaults.darkLogo)
: undefined;
const brandPath =
withDefaults.brand != null
? path.resolve(rootPath, withDefaults.brand)
: undefined;
const darkBrandPath =
withDefaults.darkBrand != null
? path.resolve(rootPath, withDefaults.darkBrand)
: undefined;
const logoWidth = withDefaults.logoWidth - (withDefaults.logoWidth % 2);
const brandWidth = withDefaults.brandWidth - (withDefaults.brandWidth % 2);
const [logo, darkLogo, brand, darkBrand] = await Promise.all([
toAsset(logoPath, logoWidth),
darkLogoPath != null ? toAsset(darkLogoPath, logoWidth) : undefined,
brandPath != null ? toAsset(brandPath, brandWidth) : undefined,
darkBrandPath != null ? toAsset(darkBrandPath, brandWidth) : undefined,
]);
const background = parseColor(withDefaults.background);
const darkBackground =
withDefaults.darkBackground != null
? parseColor(withDefaults.darkBackground)
: undefined;
const executeAddon =
brand != null ||
darkBackground != null ||
darkLogo != null ||
darkBrand != null;
if (licenseKey != null && !executeAddon) {
log.warn(
"You specified a license key but none of the options that requires it.",
);
}
const isPluginLoggerMode = loggerMode.type === "plugin";
const optionNames = {
brand: isPluginLoggerMode ? "brand" : "--brand",
darkBackground: isPluginLoggerMode ? "darkBackground" : "--dark-background",
darkLogo: isPluginLoggerMode ? "darkLogo" : "--dark-logo",
darkBrand: isPluginLoggerMode ? "darkBrand" : "--dark-brand",
};
if (licenseKey == null && executeAddon) {
const options = [
brand != null ? optionNames.brand : "",
darkBackground != null ? optionNames.darkBackground : "",
darkLogo != null ? optionNames.darkLogo : "",
darkBrand != null ? optionNames.darkBrand : "",
]
.filter((option) => option !== "")
.join(", ");
log.error(`You need to specify a license key in order to use ${options}.`);
process.exit(1);
}
if (brand == null && darkBrand != null) {
log.error(
`${optionNames.darkBrand} option couldn't be used without ${optionNames.brand}.`,
);
process.exit(1);
}
if (logoWidth < withDefaults.logoWidth) {
log.warn(
`Logo width must be a multiple of 2. It has been rounded to ${logoWidth}dp.`,
);
}
if (brandWidth < withDefaults.brandWidth) {
log.warn(
`Brand width must be a multiple of 2. It has been rounded to ${brandWidth}dp.`,
);
}
const logoSizeExceeded = logo.width > 192 || logo.height > 192;
const brandSizeExceeded =
brand != null && (brand.width > 200 || brand.height > 80);
const record: Record<string, string> = {
background: background.hex,
darkBackground: darkBackground?.hex ?? "",
logo: logo.hash,
darkLogo: darkLogo?.hash ?? "",
brand: brand?.hash ?? "",
darkBrand: darkBrand?.hash ?? "",
};
const stableKey = Object.keys(record)
.sort()
.map((key) => record[key])
.join();
const fileNameSuffix = crypto
.createHash("shake256", { outputLength: 3 })
.update(stableKey)
.digest("hex")
.toLowerCase();
return {
android,
assetsOutputPath,
licenseKey,
executeAddon,
background,
darkBackground,
logo,
darkLogo,
brand,
darkBrand,
logoSizeExceeded,
brandSizeExceeded,
fileNameSuffix,
};
};
export type Props = Awaited<ReturnType<typeof transformProps>>;
export const writeAndroidAssets = async ({
androidOutputPath,
props,
}: {
androidOutputPath: string;
props: Props;
}) => {
const { logo, logoSizeExceeded } = props;
log.title("🤖", "Android");
hfs.ensureDir(androidOutputPath);
if (logoSizeExceeded) {
return log.warn(
"Logo exceeds 192x192dp and will be cropped by Android. Skipping its generation.",
);
}
if (logo.width > 134 || logo.height > 134) {
log.warn("Logo exceeds 134x134dp. It might be cropped by Android.");
}
await Promise.all(
[
{ ratio: 1, suffix: "mdpi" },
{ ratio: 1.5, suffix: "hdpi" },
{ ratio: 2, suffix: "xhdpi" },
{ ratio: 3, suffix: "xxhdpi" },
{ ratio: 4, suffix: "xxxhdpi" },
].map(({ ratio, suffix }) => {
const drawableDirPath = path.resolve(
androidOutputPath,
`drawable-${suffix}`,
);
hfs.ensureDir(drawableDirPath);
// https://developer.android.com/develop/ui/views/launch/splash-screen#dimensions
const canvasSize = 288 * ratio;
// https://sharp.pixelplumbing.com/api-constructor
const canvas = sharp({
create: {
width: canvasSize,
height: canvasSize,
channels: 4,
background: {
r: 255,
g: 255,
b: 255,
alpha: 0,
},
},
});
const filePath = path.resolve(drawableDirPath, "bootsplash_logo.png");
return logo.image
.clone()
.resize(logo.width * ratio)
.toBuffer()
.then((input) =>
canvas.composite([{ input }]).png({ quality: 100 }).toFile(filePath),
)
.then(() => {
log.write(filePath, {
width: canvasSize,
height: canvasSize,
});
});
}),
);
};
const getStoryboard = (props: Props) => {
const { background, logo, fileNameSuffix } = props;
const { R, G, B } = background.rgb;
const frameWidth = 375;
const frameHeight = 667;
return dedent`
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController modalTransitionStyle="crossDissolve" id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" autoresizesSubviews="NO" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="${frameWidth}" height="${frameHeight}"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView autoresizesSubviews="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="BootSplashLogo-${fileNameSuffix}" translatesAutoresizingMaskIntoConstraints="NO" id="3lX-Ut-9ad">
<rect key="frame" x="${(frameWidth - logo.width) / 2}" y="${(frameHeight - logo.height) / 2}" width="${logo.width}" height="${logo.height}"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
</accessibility>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" name="BootSplashBackground-${fileNameSuffix}"/>
<constraints>
<constraint firstItem="3lX-Ut-9ad" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="Fh9-Fy-1nT"/>
<constraint firstItem="3lX-Ut-9ad" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="nvB-Ic-PnI"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="0.0"/>
</scene>
</scenes>
<resources>
<image name="BootSplashLogo-${fileNameSuffix}" width="${logo.width}" height="${logo.height}"/>
<namedColor name="BootSplashBackground-${fileNameSuffix}">
<color red="${R}" green="${G}" blue="${B}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>
`;
};
export const writeIOSAssets = async ({
iosOutputPath,
props,
}: {
iosOutputPath: string;
props: Props;
}) => {
const { background, logo, fileNameSuffix } = props;
log.title("🍏", "iOS");
hfs.ensureDir(iosOutputPath);
// clean existing assets
hfs
.readDir(iosOutputPath)
.filter((file) => file === "Colors.xcassets" || file === "Images.xcassets")
.map((file) => path.join(iosOutputPath, file))
.flatMap((dir) =>
hfs
.readDir(dir)
.filter((file) => file.startsWith("BootSplash"))
.map((file) => path.join(dir, file)),
)
.forEach((file) => {
hfs.rm(file);
});
const storyboardPath = path.resolve(iosOutputPath, "BootSplash.storyboard");
await writeXmlLike(storyboardPath, getStoryboard(props), {
formatter: "xmlFormatter",
whiteSpaceAtEndOfSelfclosingTag: false,
});
const colorsSetPath = path.resolve(
iosOutputPath,
"Colors.xcassets",
`BootSplashBackground-${fileNameSuffix}.colorset`,
);
hfs.ensureDir(colorsSetPath);
writeJson(path.resolve(colorsSetPath, "Contents.json"), {
colors: [
{
idiom: "universal",
color: {
"color-space": "srgb",
components: {
blue: background.rgb.B,
green: background.rgb.G,
red: background.rgb.R,
alpha: "1.000",
},
},
},
],
info: {
author: "xcode",
version: 1,
},
});
const logoFileName = `logo-${fileNameSuffix}`;
const imagesSetPath = path.resolve(
iosOutputPath,
"Images.xcassets",
`BootSplashLogo-${fileNameSuffix}.imageset`,
);
hfs.ensureDir(imagesSetPath);
writeJson(path.resolve(imagesSetPath, "Contents.json"), {
images: [
{
idiom: "universal",
filename: `${logoFileName}.png`,
scale: "1x",
},
{
idiom: "universal",
filename: `${logoFileName}@2x.png`,
scale: "2x",
},
{
idiom: "universal",
filename: `${logoFileName}@3x.png`,
scale: "3x",
},
],
info: {
author: "xcode",
version: 1,
},
});
await Promise.all(
[
{ ratio: 1, suffix: "" },
{ ratio: 2, suffix: "@2x" },
{ ratio: 3, suffix: "@3x" },
].map(({ ratio, suffix }) => {
const filePath = path.resolve(
imagesSetPath,
`${logoFileName}${suffix}.png`,
);
return logo.image
.clone()
.resize(logo.width * ratio)
.png({ quality: 100 })
.toFile(filePath)
.then(({ width, height }) => {
log.write(filePath, { width, height });
});
}),
);
};
export const writeWebAssets = async ({
htmlTemplatePath,
props,
}: {
htmlTemplatePath: string;
props: Props;
}) => {
const { background, logo } = props;
log.title("🌐", "Web");
const htmlTemplate = readXmlLike(htmlTemplatePath);
const { format } = await logo.image.metadata();
const prevStyle = htmlTemplate.root.querySelector("#bootsplash-style");
const base64 = (
format === "svg"
? hfs.buffer(logo.path)
: await logo.image
.clone()
.resize(Math.round(logo.width * 2))
.png({ quality: 100 })
.toBuffer()
).toString("base64");
const dataURI = `data:image/${format ? "svg+xml" : "png"};base64,${base64}`;
const nextStyle = parseHtml(dedent`
<style id="bootsplash-style">
#bootsplash {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
background-color: ${background.hex};
}
#bootsplash-logo {
content: url("${dataURI}");
width: ${logo.width}px;
height: ${logo.height}px;
}
</style>
`);
if (prevStyle != null) {
prevStyle.replaceWith(nextStyle);
} else {
htmlTemplate.root.querySelector("head")?.appendChild(nextStyle);
}
const prevDiv = htmlTemplate.root.querySelector("#bootsplash");
const nextDiv = parseHtml(dedent`
<div id="bootsplash">
<div id="bootsplash-logo"></div>
</div>
`);
if (prevDiv != null) {
prevDiv.replaceWith(nextDiv);
} else {
htmlTemplate.root.querySelector("body")?.appendChild(nextDiv);
}
await writeXmlLike(htmlTemplatePath, htmlTemplate.root.toString(), {
...htmlTemplate.formatOptions,
formatter: "prettier",
useCssPlugin: true,
});
};
export const writeGenericAssets = async ({ props }: { props: Props }) => {
const { assetsOutputPath, background, logo } = props;
log.title("📄", "Assets");
hfs.ensureDir(assetsOutputPath);
writeJson(path.resolve(assetsOutputPath, "manifest.json"), {
background: background.hex,
logo: {
width: logo.width,
height: logo.height,
},
} satisfies Manifest);
await Promise.all(
[
{ ratio: 1, suffix: "" },
{ ratio: 1.5, suffix: "@1,5x" },
{ ratio: 2, suffix: "@2x" },
{ ratio: 3, suffix: "@3x" },
{ ratio: 4, suffix: "@4x" },
].map(({ ratio, suffix }) => {
const filePath = path.resolve(assetsOutputPath, `logo${suffix}.png`);
return logo.image
.clone()
.resize(Math.round(logo.width * ratio))
.png({ quality: 100 })
.toFile(filePath)
.then(({ width, height }) => {
log.write(filePath, { width, height });
});
}),
);
};
export type AddonConfig = {
props: Props;
androidOutputPath: string | undefined;
iosOutputPath: string | undefined;
htmlTemplatePath: string | undefined;
};
export const requireAddon = ({
executeAddon,
licenseKey,
}: Props):
| {
execute: (config: AddonConfig) => Promise<void>;
writeAndroidAssets: (_: {
androidOutputPath: string;
props: Props;
}) => Promise<void>;
writeIOSAssets: (_: {
iosOutputPath: string;
props: Props;
}) => Promise<void>;
writeWebAssets: (_: {
htmlTemplatePath: string;
props: Props;
}) => Promise<void>;
writeGenericAssets: (_: { props: Props }) => Promise<void>;
withAndroidColorsNight: (_: {
config: Expo.ExportedConfigWithProps;
props: Props;
}) => Promise<Expo.ExportedConfigWithProps>;
}
| undefined => {
if (licenseKey != null && executeAddon) {
try {
const addon = require("./addon");
return "default" in addon ? addon.default : addon;
} catch {
return;
}
}
};