@react-native-ohos/react-native-bootsplash
Version:
Display a bootsplash on your app starts. Hide it when you want.
1,465 lines (1,264 loc) • 39.4 kB
text/typescript
/*
* Copyright (c) 2025 Huawei Device Co., Ltd. All rights reserved
* Use of this source code is governed by a MIT license that can be
* found in the LICENSE file.
*/
import * as Expo from "@expo/config-plugins";
import plist from "@expo/plist";
import { projectConfig as getAndroidProjectConfig } from "@react-native-community/cli-config-android";
import { getProjectConfig as getAppleProjectConfig } from "@react-native-community/cli-config-apple";
import { findProjectRoot } from "@react-native-community/cli-tools";
import {
AndroidProjectConfig,
IOSProjectConfig,
} from "@react-native-community/cli-types";
import childProcess from "child_process";
import crypto from "crypto";
import detectIndent from "detect-indent";
import fs from "fs-extra";
import { HTMLElement, parse as parseHtml } from "node-html-parser";
import path from "path";
import pc from "picocolors";
import { 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, { Sharp } from "sharp";
import { dedent } from "ts-dedent";
import util from "util";
import formatXml, { XMLFormatterOptions } from "xml-formatter";
import { Manifest } from ".";
import JSON5 from 'json5';
const workingPath = process.env.INIT_CWD ?? process.env.PWD ?? process.cwd();
const projectRoot = findProjectRoot(workingPath);
const getIOSProjectConfig = getAppleProjectConfig({ platformName: "ios" });
getIOSProjectConfig(projectRoot, {});
getAndroidProjectConfig(projectRoot);
type PackageJson = {
version?: string;
dependencies?: Record<string, string>;
};
type ProjectType = "detect" | "bare" | "expo";
type Platforms = ("android" | "ios" | "web" | "harmony")[];
export type RGBColor = {
R: string;
G: string;
B: string;
};
type Color = {
hex: string;
rgb: RGBColor;
};
const promisifiedExec = util.promisify(childProcess.exec);
const exec = (cmd: string) =>
promisifiedExec(cmd).then(({ stdout, stderr }) => stdout || stderr);
export const log = {
error: (text: string) => {
console.log(pc.red(`❌ ${text}`));
},
title: (emoji: string, text: string) => {
console.log(`\n${emoji} ${pc.underline(pc.bold(text))}`);
},
warn: (text: string) => {
console.log(pc.yellow(`⚠️ ${text}`));
},
write: (filePath: string, dimensions?: { width: number; height: number }) => {
console.log(
` ${path.relative(workingPath, filePath)}` +
(dimensions != null
? ` (${dimensions.width}x${dimensions.height})`
: ""),
);
},
};
const parseColor = (value: string): Color => {
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: Color["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 };
};
const getStoryboard = ({
logoHeight,
logoWidth,
background: { R, G, B },
fileNameSuffix,
}: {
logoHeight: number;
logoWidth: number;
background: Color["rgb"];
fileNameSuffix: string;
}) => {
const frameWidth = 375;
const frameHeight = 667;
const logoX = (frameWidth - logoWidth) / 2;
const logoY = (frameHeight - logoHeight) / 2;
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="${logoX}" y="${logoY}" width="${logoWidth}" height="${logoHeight}"/>
<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="${logoWidth}" height="${logoHeight}"/>
<namedColor name="BootSplashBackground-${fileNameSuffix}">
<color red="${R}" green="${G}" blue="${B}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>
`;
};
// Freely inspired by https://github.com/humanwhocodes/humanfs
export const hfs = {
buffer: (path: string) => fs.readFileSync(path),
exists: (path: string) => fs.existsSync(path),
isDir: (path: string) => fs.lstatSync(path).isDirectory(),
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"),
copy: (src: string, dest: string) => {
if (hfs.isDir(src) || !hfs.exists(dest)) {
return fs.copySync(src, dest, { overwrite: true });
}
const srcBuffer = fs.readFileSync(src);
const destBuffer = fs.readFileSync(dest);
if (!srcBuffer.equals(destBuffer)) {
return fs.copySync(src, dest, { overwrite: true });
}
},
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");
},
};
const findUp = <T>(from: string, matcher: (dir: string) => T | undefined) => {
let previous: string | undefined;
let current = path.normalize(from);
do {
const found = matcher(current);
if (typeof found !== "undefined") {
return found;
}
previous = current;
current = path.dirname(current);
} while (current !== previous);
};
export const getExpoConfig = (from: string): { isExpo: boolean } => {
const hasDependency =
findUp(from, (dir) => {
const pkgPath = path.resolve(dir, "package.json");
if (fs.existsSync(pkgPath)) {
try {
const pkg = hfs.json(pkgPath) as PackageJson;
return pkg.dependencies?.expo != null;
} catch {}
}
}) ?? false;
if (!hasDependency) {
return { isExpo: false };
}
const version = findUp(from, (dir) => {
const pkgPath = path.resolve(dir, "node_modules", "expo", "package.json");
if (fs.existsSync(pkgPath)) {
try {
const pkg = hfs.json(pkgPath) as PackageJson;
return pkg.version;
} catch {}
}
});
if (version == null || semver.lt(version, "51.0.20")) {
log.error("Requires Expo 51.0.20 (or higher)");
process.exit(1);
}
return { isExpo: true };
};
export const writeJson = (filePath: string, content: object) => {
hfs.write(filePath, JSON.stringify(content, null, 2));
log.write(filePath);
};
type FormatOptions = { indent?: detectIndent.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 const cleanIOSAssets = (dir: string) => {
hfs
.readDir(dir)
.filter((file) => file === "Colors.xcassets" || file === "Images.xcassets")
.map((file) => path.join(dir, file))
.flatMap((dir) =>
hfs
.readDir(dir)
.filter((file) => file.startsWith("BootSplash"))
.map((file) => path.join(dir, file)),
)
.forEach((file) => {
hfs.rm(file);
});
};
const getImageBase64 = async (
image: Sharp | undefined,
width: number,
): Promise<string> => {
if (image == null) {
return "";
}
const buffer = await image
.clone()
.resize(width)
.png({ quality: 100 })
.toBuffer();
return buffer.toString("base64");
};
const getFileNameSuffix = async ({
background,
brand,
brandWidth,
darkBackground,
darkBrand,
darkLogo,
logo,
logoWidth,
}: {
background: Color;
brand: Sharp | undefined;
brandWidth: number;
darkBackground: Color | undefined;
darkBrand: Sharp | undefined;
darkLogo: Sharp | undefined;
logo: Sharp;
logoWidth: number;
}) => {
const [logoHash, darkLogoHash, brandHash, darkBrandHash] = await Promise.all([
getImageBase64(logo, logoWidth),
getImageBase64(darkLogo, logoWidth),
getImageBase64(brand, brandWidth),
getImageBase64(darkBrand, brandWidth),
]);
const record: Record<string, string> = {
background: background.hex,
darkBackground: darkBackground?.hex ?? "",
logo: logoHash,
darkLogo: darkLogoHash,
brand: brandHash,
darkBrand: darkBrandHash,
};
const stableKey = Object.keys(record)
.sort()
.map((key) => record[key])
.join();
return crypto
.createHash("shake256", { outputLength: 3 })
.update(stableKey)
.digest("hex")
.toLowerCase();
};
const ensureSupportedFormat = async (
name: string,
image: Sharp | undefined,
) => {
if (image == null) {
return;
}
const { format } = await image.metadata();
if (format !== "png" && format !== "svg") {
log.error(`${name} image file format (${format}) is not supported`);
process.exit(1);
}
};
const getAndroidOutputPath = ({
android,
assetsOutputPath,
brandHeight,
brandWidth,
flavor,
isExpo,
logoHeight,
logoWidth,
platforms,
}: {
android: AndroidProjectConfig | undefined;
assetsOutputPath: string;
brandHeight: number;
brandWidth: number;
flavor: string;
isExpo: boolean;
logoHeight: number;
logoWidth: number;
platforms: Platforms;
}) => {
if (!platforms.includes("android")) {
return;
}
if (isExpo) {
return path.resolve(assetsOutputPath, "android");
}
if (android == null) {
return;
}
const androidOutputPath = path.resolve(
android.sourceDir,
android.appName,
"src",
flavor,
"res",
);
if (!hfs.exists(androidOutputPath)) {
return log.warn(
`No ${path.relative(
workingPath,
androidOutputPath,
)} directory found. Skipping Android assets generation…`,
);
}
if (logoWidth > 288 || logoHeight > 288) {
return log.warn(
"Logo size exceeding 288x288dp will be cropped by Android. Skipping Android assets generation…",
);
}
if (brandWidth > 200 || brandHeight > 80) {
return log.warn(
"Brand size exceeding 200x80dp will be cropped by Android. Skipping Android assets generation…",
);
}
if (logoWidth > 192 || logoHeight > 192) {
log.warn("Logo size exceeds 192x192dp. It might be cropped by Android.");
}
return androidOutputPath;
};
const getIOSOutputPath = ({
ios,
assetsOutputPath,
isExpo,
platforms,
}: {
ios: IOSProjectConfig | undefined;
assetsOutputPath: string;
isExpo: boolean;
platforms: Platforms;
}) => {
if (!platforms.includes("ios")) {
return;
}
if (isExpo) {
return path.resolve(assetsOutputPath, "ios");
}
if (ios == null) {
return;
}
if (ios.xcodeProject == null) {
return log.warn("No Xcode project found. Skipping iOS assets generation…");
}
const iosOutputPath = path
.resolve(ios.sourceDir, ios.xcodeProject.name)
.replace(/\.(xcodeproj|xcworkspace)$/, "");
if (!hfs.exists(iosOutputPath)) {
return log.warn(
`No ${path.relative(
workingPath,
iosOutputPath,
)} directory found. Skipping iOS assets generation…`,
);
}
return iosOutputPath;
};
const getHtmlTemplatePath = async ({
isExpo,
html,
platforms,
}: {
isExpo: boolean;
html: string;
platforms: Platforms;
}) => {
if (!platforms.includes("web")) {
return;
}
if (isExpo) {
const htmlTemplatePath = path.resolve(workingPath, html);
const htmlTemplateRelativePath = path.relative(
workingPath,
htmlTemplatePath,
);
if (
htmlTemplateRelativePath === "public/index.html" &&
!hfs.exists(htmlTemplatePath)
) {
const cmd = `npx expo customize ${htmlTemplateRelativePath}`;
console.log(pc.dim(`Running ${cmd}`));
await exec(cmd);
}
}
const htmlTemplatePath = path.resolve(workingPath, html);
if (!hfs.exists(htmlTemplatePath)) {
return log.warn(
`No ${path.relative(
workingPath,
htmlTemplatePath,
)} file found. Skipping HTML + CSS generation…`,
);
}
return htmlTemplatePath;
};
const getImageHeight = (
image: Sharp | undefined,
width: number,
): Promise<number> => {
if (image == null) {
return Promise.resolve(0);
}
return image
.clone()
.resize(width)
.toBuffer()
.then((buffer) => sharp(buffer).metadata())
.then(({ height = 0 }) => Math.round(height));
};
const getHarmonyOutputPath = ({
assetsOutputPath,
isExpo,
flavor,
platforms,
}: {
assetsOutputPath: string;
isExpo: boolean;
flavor: string;
platforms: Platforms;
}) => {
if (!platforms.includes("harmony")) {
return;
}
if (isExpo) {
return path.resolve(assetsOutputPath, "harmony");
}
const harmonyOutputPath = path.resolve(
"harmony",
"entry",
"src",
flavor,
"resources",
);
if (!hfs.exists(harmonyOutputPath)) {
return log.warn(
`No ${path.relative(workingPath, harmonyOutputPath)} directory found. Skipping HarmonyOS assets generation…`,
);
}
return harmonyOutputPath;
};
const getColorResourcesIfExists = (filePath: string) => {
if (fs.existsSync(filePath)) {
const jsonStr = fs.readFileSync(filePath).toString();
const colorJson = JSON.parse(jsonStr);
return colorJson.color;
}
};
const getMergedColorResources = (colorsSetPath: string, background: Color) => {
const resources = getColorResourcesIfExists(colorsSetPath);
const colorsMap = resources && resources.length ? resources : [];
const manualResources =
colorsMap
.filter((color: any) => color["name"] !== "bootsplash_background")
.map((color: any) => color) || [];
const generatedResources = [
{
name: "bootsplash_background",
value: background.hex,
},
];
const mergeResources = {
color: [...manualResources, ...generatedResources],
};
return mergeResources;
};
const makeHarmonyResourceFile = async ({
harmonyOutputPath,
logo,
logoWidth,
background,
darkBackground,
isExpo,
}: {
harmonyOutputPath: string;
logo: Sharp;
logoWidth: number;
background?: Color;
darkBackground?: Color;
isExpo: boolean;
}) => {
log.title("", "HarmonyOS");
hfs.ensureDir(harmonyOutputPath);
await Promise.all(
[{ ratio: 1, suffix: "base" }].map(({ ratio, suffix }) => {
const imageSetPath = path.resolve(
harmonyOutputPath,
`${suffix}`,
"media",
);
hfs.ensureDir(imageSetPath);
const logoFilePath = path.resolve(imageSetPath, "bootsplash_logo.png");
return logo
.clone()
.resize(logoWidth * ratio)
.png({
quality: 100,
})
.toFile(logoFilePath)
.then(({ width, height }) => {
log.write(logoFilePath, {
width,
height,
});
});
}),
);
if (background) {
const lightColorsSetPath = path.resolve(
harmonyOutputPath,
"base",
"element",
"color.json",
);
if (!hfs.exists(lightColorsSetPath)) {
hfs.ensureDir(lightColorsSetPath);
}
const lightColorResouces = getMergedColorResources(
lightColorsSetPath,
background,
);
writeJson(lightColorsSetPath, lightColorResouces);
}
if (darkBackground) {
const darkColorsSetPath = path.resolve(
harmonyOutputPath,
"dark",
"element",
"color.json",
);
if (!hfs.exists(darkColorsSetPath)) {
hfs.ensureDir(darkColorsSetPath);
}
const darkColorResouces = getMergedColorResources(
darkColorsSetPath,
darkBackground,
);
writeJson(darkColorsSetPath, darkColorResouces);
}
if (!isExpo) {
const moduleSetPath = path.resolve(harmonyOutputPath, "..", "module.json5");
const content = hfs.text(moduleSetPath);
try {
const moduleJson = JSON5.parse(content);
const moduleNode = moduleJson["module"];
const abilities = moduleNode["abilities"];
for (const abilitie of abilities) {
if (abilitie["startWindowIcon"]) {
abilitie["startWindowIcon"] = "$media:bootsplash_logo";
abilitie["startWindowBackground"] = "$color:bootsplash_background";
break;
}
}
writeJson(moduleSetPath, moduleJson);
} catch (err) {
log.error("parse module.json5 error!" + err);
}
}
};
export type AddonConfig = {
licenseKey: string;
isExpo: boolean;
fileNameSuffix: string;
androidOutputPath: string | void;
iosOutputPath: string | void;
htmlTemplatePath: string | void;
assetsOutputPath: string;
logoPath: string;
darkLogoPath: string | undefined;
brandPath: string | undefined;
darkBrandPath: string | undefined;
logoHeight: number;
logoWidth: number;
brandHeight: number;
brandWidth: number;
background: Color;
logo: Sharp;
brand: Sharp | undefined;
darkBackground: Color | undefined;
darkLogo: Sharp | undefined;
darkBrand: Sharp | undefined;
};
const requireAddon = ():
| { execute: (config: AddonConfig) => Promise<void> }
| undefined => {
try {
return require("./addon"); // eslint-disable-line
} catch {
return;
}
};
export const generate = async ({
android,
ios,
projectType,
platforms,
html,
flavor,
licenseKey,
...args
}: {
android?: AndroidProjectConfig;
ios?: IOSProjectConfig;
logo: string;
projectType: ProjectType;
platforms: Platforms;
background: string;
logoWidth: number;
assetsOutput: string;
html: string;
flavor: string;
licenseKey?: string;
brand?: string;
brandWidth: number;
darkBackground?: string;
darkLogo?: string;
darkBrand?: string;
}) => {
const isExpo =
projectType === "expo" ||
(projectType === "detect" && getExpoConfig(workingPath).isExpo);
if (semver.lt(process.versions.node, "18.0.0")) {
log.error("Requires Node 18 (or higher)");
process.exit(1);
}
const logoPath = path.resolve(workingPath, args.logo);
const darkLogoPath =
args.darkLogo != null
? path.resolve(workingPath, args.darkLogo)
: undefined;
const brandPath =
args.brand != null ? path.resolve(workingPath, args.brand) : undefined;
const darkBrandPath =
args.darkBrand != null
? path.resolve(workingPath, args.darkBrand)
: undefined;
const assetsOutputPath = path.resolve(workingPath, args.assetsOutput);
const logo = sharp(logoPath);
const darkLogo = darkLogoPath != null ? sharp(darkLogoPath) : undefined;
const brand = brandPath != null ? sharp(brandPath) : undefined;
const darkBrand = darkBrandPath != null ? sharp(darkBrandPath) : undefined;
const background = parseColor(args.background);
const logoWidth = args.logoWidth - (args.logoWidth % 2);
const brandWidth = args.brandWidth - (args.brandWidth % 2);
const darkBackground =
args.darkBackground != null ? parseColor(args.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.",
);
}
if (licenseKey == null && executeAddon) {
const options = [
brand != null ? "brand" : "",
darkBackground != null ? "dark-background" : "",
darkLogo != null ? "dark-logo" : "",
darkBrand != null ? "dark-brand" : "",
]
.filter((option) => option !== "")
.map((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("--dark-brand option couldn't be used without --brand.");
process.exit(1);
}
await ensureSupportedFormat("Logo", logo);
await ensureSupportedFormat("Dark logo", darkLogo);
await ensureSupportedFormat("Brand", brand);
await ensureSupportedFormat("Dark brand", darkBrand);
const logoHeight = await getImageHeight(logo, logoWidth);
const brandHeight = await getImageHeight(brand, brandWidth);
if (logoWidth < args.logoWidth) {
log.warn(
`Logo width must be a multiple of 2. It has been rounded to ${logoWidth}dp.`,
);
}
if (brandWidth < args.brandWidth) {
log.warn(
`Brand width must be a multiple of 2. It has been rounded to ${brandWidth}dp.`,
);
}
const fileNameSuffix = await getFileNameSuffix({
background,
brand,
brandWidth,
darkBackground,
darkBrand,
darkLogo,
logo,
logoWidth,
});
const androidOutputPath = getAndroidOutputPath({
android,
assetsOutputPath,
brandHeight,
brandWidth,
flavor,
isExpo,
logoHeight,
logoWidth,
platforms,
});
const iosOutputPath = getIOSOutputPath({
assetsOutputPath,
ios,
isExpo,
platforms,
});
const htmlTemplatePath = await getHtmlTemplatePath({
isExpo,
html,
platforms,
});
const harmonyOutputPath = getHarmonyOutputPath({
assetsOutputPath,
flavor,
isExpo,
platforms,
});
if (androidOutputPath != null) {
log.title("🤖", "Android");
hfs.ensureDir(androidOutputPath);
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
.clone()
.resize(logoWidth * ratio)
.toBuffer()
.then((input) =>
canvas
.composite([{ input }])
.png({ quality: 100 })
.toFile(filePath),
)
.then(() => {
log.write(filePath, {
width: canvasSize,
height: canvasSize,
});
});
}),
);
if (!isExpo) {
const manifestXmlPath = path.resolve(
androidOutputPath,
"..",
"AndroidManifest.xml",
);
if (hfs.exists(manifestXmlPath)) {
const manifestXml = readXmlLike(manifestXmlPath);
const activities = manifestXml.root.querySelectorAll("activity");
for (const activity of activities) {
if (activity.getAttribute("android:name") === ".MainActivity") {
activity.setAttribute("android:theme", "@style/BootTheme");
}
}
await writeXmlLike(manifestXmlPath, manifestXml.root.toString(), {
...manifestXml.formatOptions,
formatter: "prettier",
htmlWhitespaceSensitivity: "ignore",
selfClosingTags: true,
singleAttributePerLine: true,
});
} else {
log.warn("No AndroidManifest.xml found. Skipping…");
}
const valuesPath = path.resolve(androidOutputPath, "values");
hfs.ensureDir(valuesPath);
const colorsXmlPath = path.resolve(valuesPath, "colors.xml");
const colorsXmlEntry = `<color name="bootsplash_background">${background.hex}</color>`;
if (hfs.exists(colorsXmlPath)) {
const colorsXml = readXmlLike(colorsXmlPath);
const nextColor = parseHtml(colorsXmlEntry);
const prevColor = colorsXml.root.querySelector(
'color[name="bootsplash_background"]',
);
if (prevColor != null) {
prevColor.replaceWith(nextColor);
} else {
colorsXml.root.querySelector("resources")?.appendChild(nextColor);
}
await writeXmlLike(colorsXmlPath, colorsXml.root.toString(), {
...colorsXml.formatOptions,
formatter: "xmlFormatter",
});
} else {
await writeXmlLike(
colorsXmlPath,
`<resources>${colorsXmlEntry}</resources>`,
{ formatter: "xmlFormatter" },
);
}
const stylesXmlPath = path.resolve(valuesPath, "styles.xml");
if (hfs.exists(stylesXmlPath)) {
const stylesXml = readXmlLike(stylesXmlPath);
const prevStyle = stylesXml.root.querySelector(
'style[name="BootTheme"]',
);
const parent = prevStyle?.getAttribute("parent") ?? "Theme.BootSplash";
const extraItems = parseHtml(
prevStyle?.text
.split("\n")
.map((line) => line.trim())
.join("") ?? "",
)
.childNodes.filter((node) => {
if (!(node instanceof HTMLElement)) {
return true;
}
const name = node.getAttribute("name");
return (
name !== "bootSplashBackground" &&
name !== "bootSplashLogo" &&
name !== "bootSplashBrand" &&
name !== "postBootSplashTheme"
);
})
.map((node) => node.toString());
const styleItems: string[] = [
...(extraItems.length > 0 ? [...extraItems, ""] : []),
'<item name="bootSplashBackground">@color/bootsplash_background</item>',
'<item name="bootSplashLogo">@drawable/bootsplash_logo</item>',
...(brand != null && brandPath != null
? ['<item name="bootSplashBrand">@drawable/bootsplash_brand</item>']
: []),
'<item name="postBootSplashTheme">@style/AppTheme</item>',
];
const nextStyle = parseHtml(dedent`
<style name="BootTheme" parent="${parent}">
${styleItems.join("\n")}
</style>
`);
prevStyle?.remove(); // remove the existing style
stylesXml.root.querySelector("resources")?.appendChild(nextStyle);
await writeXmlLike(stylesXmlPath, stylesXml.root.toString(), {
...stylesXml.formatOptions,
formatter: "prettier",
htmlWhitespaceSensitivity: "ignore",
});
} else {
log.warn("No styles.xml found. Skipping…");
}
}
}
if (iosOutputPath != null) {
log.title("🍏", "iOS");
hfs.ensureDir(iosOutputPath);
cleanIOSAssets(iosOutputPath);
const storyboardPath = path.resolve(iosOutputPath, "BootSplash.storyboard");
const colorsSetPath = path.resolve(
iosOutputPath,
"Colors.xcassets",
`BootSplashBackground-${fileNameSuffix}.colorset`,
);
const imageSetPath = path.resolve(
iosOutputPath,
"Images.xcassets",
`BootSplashLogo-${fileNameSuffix}.imageset`,
);
hfs.ensureDir(colorsSetPath);
hfs.ensureDir(imageSetPath);
await writeXmlLike(
storyboardPath,
getStoryboard({
logoHeight,
logoWidth,
background: background.rgb,
fileNameSuffix,
}),
{
formatter: "xmlFormatter",
whiteSpaceAtEndOfSelfclosingTag: false,
},
);
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}`;
writeJson(path.resolve(imageSetPath, "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(
imageSetPath,
`${logoFileName}${suffix}.png`,
);
return logo
.clone()
.resize(logoWidth * ratio)
.png({ quality: 100 })
.toFile(filePath)
.then(({ width, height }) => {
log.write(filePath, { width, height });
});
}),
);
if (!isExpo) {
const infoPlistPath = path.resolve(iosOutputPath, "Info.plist");
const infoPlist = plist.parse(hfs.text(infoPlistPath)) as Record<
string,
unknown
>;
infoPlist["UILaunchStoryboardName"] = "BootSplash";
const formatted = formatXml(plist.build(infoPlist), {
collapseContent: true,
forceSelfClosingEmptyTag: false,
indentation: "\t",
lineSeparator: "\n",
whiteSpaceAtEndOfSelfclosingTag: false,
})
.replace(/<string\/>/gm, "<string></string>")
.replace(/^\t/gm, "");
hfs.write(infoPlistPath, formatted);
log.write(infoPlistPath);
const pbxprojectPath =
Expo.IOSConfig.Paths.getPBXProjectPath(projectRoot);
const xcodeProjectPath =
Expo.IOSConfig.Paths.getXcodeProjectPath(projectRoot);
const project = Expo.IOSConfig.XcodeUtils.getPbxproj(projectRoot);
const projectName = path.basename(iosOutputPath);
const groupName = path.parse(xcodeProjectPath).name;
Expo.IOSConfig.XcodeUtils.addResourceFileToGroup({
project,
filepath: path.join(projectName, "BootSplash.storyboard"),
groupName,
isBuildFile: true,
});
Expo.IOSConfig.XcodeUtils.addResourceFileToGroup({
project,
filepath: path.join(projectName, "Colors.xcassets"),
groupName,
isBuildFile: true,
});
hfs.write(pbxprojectPath, project.writeSync());
log.write(pbxprojectPath);
}
}
if (htmlTemplatePath != null) {
log.title("🌐", "Web");
const htmlTemplate = readXmlLike(htmlTemplatePath);
const { format } = await logo.metadata();
const prevStyle = htmlTemplate.root.querySelector("#bootsplash-style");
const base64 = (
format === "svg"
? hfs.buffer(logoPath)
: await logo
.clone()
.resize(Math.round(logoWidth * 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: ${logoWidth}px;
height: ${logoHeight}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,
});
}
if (harmonyOutputPath != null) {
await makeHarmonyResourceFile({
harmonyOutputPath,
logo,
logoWidth,
background,
darkBackground,
isExpo,
});
}
log.title("📄", "Assets");
hfs.ensureDir(assetsOutputPath);
writeJson(path.resolve(assetsOutputPath, "manifest.json"), {
background: background.hex,
logo: {
width: logoWidth,
height: logoHeight,
},
} 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
.clone()
.resize(Math.round(logoWidth * ratio))
.png({ quality: 100 })
.toFile(filePath)
.then(({ width, height }) => {
log.write(filePath, { width, height });
});
}),
);
if (licenseKey != null && executeAddon) {
const addon = requireAddon();
await addon?.execute({
licenseKey,
isExpo,
fileNameSuffix,
androidOutputPath,
iosOutputPath,
htmlTemplatePath,
assetsOutputPath,
logoHeight,
logoWidth,
brandHeight,
brandWidth,
logoPath,
darkLogoPath,
brandPath,
darkBrandPath,
background,
logo,
brand,
darkBackground,
darkLogo,
darkBrand,
});
} else {
console.log(`
${pc.blue("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓")}
${pc.blue("┃")} 🔑 ${pc.bold(
"Get a license key for brand image / dark mode support",
)} ${pc.blue("┃")}
${pc.blue("┃")} ${pc.underline(
"https://zoontek.gumroad.com/l/bootsplash-generator",
)} ${pc.blue("┃")}
${pc.blue("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")}`);
}
console.log(
`\n💖 Thanks for using ${pc.underline("react-native-bootsplash")}`,
);
};