UNPKG

react-native-bootsplash

Version:

Display a bootsplash on your app starts. Hide it when you want.

615 lines (610 loc) 21.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.writeXmlLike = exports.writeWebAssets = exports.writeJson = exports.writeIOSAssets = exports.writeGenericAssets = exports.writeAndroidAssets = exports.transformProps = exports.setLoggerMode = exports.requireAddon = exports.readXmlLike = exports.log = exports.hfs = exports.PACKAGE_NAME = void 0; var _crypto = _interopRequireDefault(require("crypto")); var _detectIndent = _interopRequireDefault(require("detect-indent")); var _fs = _interopRequireDefault(require("fs")); var _nodeHtmlParser = require("node-html-parser"); var _path = _interopRequireDefault(require("path")); var _picocolors = _interopRequireDefault(require("picocolors")); var htmlPlugin = _interopRequireWildcard(require("prettier/plugins/html")); var cssPlugin = _interopRequireWildcard(require("prettier/plugins/postcss")); var prettier = _interopRequireWildcard(require("prettier/standalone")); var _semver = _interopRequireDefault(require("semver")); var _sharp = _interopRequireDefault(require("sharp")); var _tsDedent = require("ts-dedent"); var _xmlFormatter = _interopRequireDefault(require("xml-formatter")); function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); } function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } const PACKAGE_NAME = exports.PACKAGE_NAME = "react-native-bootsplash"; let loggerMode = { type: "plugin" }; const setLoggerMode = value => { loggerMode = value; }; exports.setLoggerMode = setLoggerMode; const warningMessages = new Set(); const errorMessages = new Set(); const log = exports.log = { warn: text => { if (loggerMode.type === "cli") { console.log(_picocolors.default.yellow(`⚠️ ${text}`)); } else { const message = `⚠️ [${PACKAGE_NAME}] ${text}`; if (!warningMessages.has(message)) { warningMessages.add(message); console.log(_picocolors.default.yellow(message)); } } }, error: text => { if (loggerMode.type === "cli") { console.log(_picocolors.default.red(`❌ ${text}`)); } else { const message = `❌ [${PACKAGE_NAME}] ${text}`; if (!errorMessages.has(message)) { errorMessages.add(message); console.log(_picocolors.default.red(message)); } } }, title: (emoji, text) => { if (loggerMode.type === "cli") { console.log(`\n${emoji} ${_picocolors.default.underline(_picocolors.default.bold(text))}`); } }, write: (filePath, dimensions) => { if (loggerMode.type === "cli") { console.log(` ${_path.default.relative(loggerMode.cwd, filePath)}` + (dimensions != null ? ` (${dimensions.width}x${dimensions.height})` : "")); } } }; // Freely inspired by https://github.com/humanwhocodes/humanfs const hfs = exports.hfs = { buffer: path => _fs.default.readFileSync(path), exists: path => _fs.default.existsSync(path), json: path => JSON.parse(_fs.default.readFileSync(path, "utf-8")), readDir: path => _fs.default.readdirSync(path, "utf-8"), realPath: path => _fs.default.realpathSync(path, "utf-8"), rm: path => _fs.default.rmSync(path, { force: true, recursive: true }), text: path => _fs.default.readFileSync(path, "utf-8"), ensureDir: dir => { _fs.default.mkdirSync(dir, { recursive: true }); }, write: (path, content) => { const trimmed = content.trim(); _fs.default.writeFileSync(path, trimmed === "" ? trimmed : trimmed + "\n", "utf-8"); } }; const writeJson = (filePath, content) => { hfs.write(filePath, JSON.stringify(content, null, 2)); log.write(filePath); }; exports.writeJson = writeJson; const readXmlLike = filePath => { const content = hfs.text(filePath); return { root: (0, _nodeHtmlParser.parse)(content), formatOptions: { indent: (0, _detectIndent.default)(content) } }; }; exports.readXmlLike = readXmlLike; const writeXmlLike = async (filePath, content, { indent, ...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 = (0, _xmlFormatter.default)(content, { collapseContent: true, forceSelfClosingEmptyTag: true, lineSeparator: "\n", whiteSpaceAtEndOfSelfclosingTag: true, indentation: (indent?.indent ?? "") || " ", ...options }); hfs.write(filePath, formatted); log.write(filePath); } }; exports.writeXmlLike = writeXmlLike; const toAsset = async (filePath, width) => { const image = (0, _sharp.default)(filePath); const { format } = await image.metadata(); if (format !== "png" && format !== "svg") { log.error(`${_path.default.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 => (0, _sharp.default)(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 => { 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 }; }; const transformProps = async (rootPath, { android = {}, licenseKey, ...rawProps }) => { if (_semver.default.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.default.resolve(rootPath, withDefaults.assetsOutput); const logoPath = _path.default.resolve(rootPath, withDefaults.logo); const darkLogoPath = withDefaults.darkLogo != null ? _path.default.resolve(rootPath, withDefaults.darkLogo) : undefined; const brandPath = withDefaults.brand != null ? _path.default.resolve(rootPath, withDefaults.brand) : undefined; const darkBrandPath = withDefaults.darkBrand != null ? _path.default.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 = { 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.default.createHash("shake256", { outputLength: 3 }).update(stableKey).digest("hex").toLowerCase(); return { android, assetsOutputPath, licenseKey, executeAddon, background, darkBackground, logo, darkLogo, brand, darkBrand, logoSizeExceeded, brandSizeExceeded, fileNameSuffix }; }; exports.transformProps = transformProps; const writeAndroidAssets = async ({ androidOutputPath, 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.default.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 = (0, _sharp.default)({ create: { width: canvasSize, height: canvasSize, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 0 } } }); const filePath = _path.default.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 }); }); })); }; exports.writeAndroidAssets = writeAndroidAssets; const getStoryboard = props => { const { background, logo, fileNameSuffix } = props; const { R, G, B } = background.rgb; const frameWidth = 375; const frameHeight = 667; return (0, _tsDedent.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> `; }; const writeIOSAssets = async ({ iosOutputPath, 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.default.join(iosOutputPath, file)).flatMap(dir => hfs.readDir(dir).filter(file => file.startsWith("BootSplash")).map(file => _path.default.join(dir, file))).forEach(file => { hfs.rm(file); }); const storyboardPath = _path.default.resolve(iosOutputPath, "BootSplash.storyboard"); await writeXmlLike(storyboardPath, getStoryboard(props), { formatter: "xmlFormatter", whiteSpaceAtEndOfSelfclosingTag: false }); const colorsSetPath = _path.default.resolve(iosOutputPath, "Colors.xcassets", `BootSplashBackground-${fileNameSuffix}.colorset`); hfs.ensureDir(colorsSetPath); writeJson(_path.default.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.default.resolve(iosOutputPath, "Images.xcassets", `BootSplashLogo-${fileNameSuffix}.imageset`); hfs.ensureDir(imagesSetPath); writeJson(_path.default.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.default.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 }); }); })); }; exports.writeIOSAssets = writeIOSAssets; const writeWebAssets = async ({ htmlTemplatePath, 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 = (0, _nodeHtmlParser.parse)((0, _tsDedent.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 = (0, _nodeHtmlParser.parse)((0, _tsDedent.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 }); }; exports.writeWebAssets = writeWebAssets; const writeGenericAssets = async ({ props }) => { const { assetsOutputPath, background, logo } = props; log.title("📄", "Assets"); hfs.ensureDir(assetsOutputPath); writeJson(_path.default.resolve(assetsOutputPath, "manifest.json"), { background: background.hex, logo: { width: logo.width, height: logo.height } }); 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.default.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 }); }); })); }; exports.writeGenericAssets = writeGenericAssets; const requireAddon = ({ executeAddon, licenseKey }) => { if (licenseKey != null && executeAddon) { try { const addon = require("./addon"); return "default" in addon ? addon.default : addon; } catch { return; } } }; exports.requireAddon = requireAddon; //# sourceMappingURL=utils.js.map