import fs from "fs-extra"; import path from "path"; import pc from "picocolors"; import sharp from "sharp"; const lightLogoFileName = "bootsplash_logo"; const darkLogoFileName = "bootsplash_logo_dark"; const xcassetName = "BootSplashLogo"; const colorAssetName = "SplashColor"; const androidColorName = "bootsplash_background"; const androidColorRegex = /#\w+<\/color>/g; const colorToRGB = (hex: string) => { const fullHexColor = toFullHexadecimal(hex); return { r: (parseInt(fullHexColor[1] + fullHexColor[2], 16) / 255).toPrecision(15), g: (parseInt(fullHexColor[3] + fullHexColor[4], 16) / 255).toPrecision(15), b: (parseInt(fullHexColor[5] + fullHexColor[6], 16) / 255).toPrecision(15), }; }; const getLogoContentsJson = (includeDarkLogo: boolean) => `{ "images": [ { "idiom": "universal", "filename": "${lightLogoFileName}.png", "scale": "1x" }, { "idiom": "universal", "filename": "${lightLogoFileName}@2x.png", "scale": "2x" }, { "idiom": "universal", "filename": "${lightLogoFileName}@3x.png", "scale": "3x" }${includeDarkLogo ? DarkImagesContentsJson : ""} ], "info": { "version": 1, "author": "xcode" } } `; const DarkImagesContentsJson = `, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "idiom": "universal", "filename": "${darkLogoFileName}.png", "scale": "1x" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "idiom": "universal", "filename": "${darkLogoFileName}@2x.png", "scale": "2x" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "idiom": "universal", "filename": "${darkLogoFileName}@3x.png", "scale": "3x" } `; const getDarkColorsContentsJson = (darkColor: string) => { const rgb = colorToRGB(darkColor); return `, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "${rgb.b}", "green" : "${rgb.g}", "red" : "${rgb.r}" } }, "idiom" : "universal" } `; }; const getColorsContentsJson = (lightColor: string, darkColor?: string) => { const rgb = colorToRGB(lightColor); return `{ "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "${rgb.b}", "green" : "${rgb.g}", "red" : "${rgb.r}" } }, "idiom" : "universal" }${darkColor ? getDarkColorsContentsJson(darkColor) : ""} ], "info" : { "author" : "xcode", "version" : 1 } }`; }; const getStoryboard = ({ height, width, }: { height: number; width: number; }) => { return ` `; }; const log = { error: (text: string) => console.log(pc.red(text)), text: (text: string) => console.log(text), warn: (text: string) => console.log(pc.yellow(text)), }; const isValidHexadecimal = (value: string) => /^#?([0-9A-F]{3}){1,2}$/i.test(value); const toFullHexadecimal = (hex: string) => { const prefixed = hex[0] === "#" ? hex : `#${hex}`; const up = prefixed.toUpperCase(); return up.length === 4 ? "#" + up[1] + up[1] + up[2] + up[2] + up[3] + up[3] : up; }; export const generate = async ({ android, ios, workingPath, logoPath, darkLogoPath, backgroundColor, darkBackgroundColor, logoWidth, flavor, assetsPath, }: { android: { sourceDir: string; appName: string; } | null; ios: { projectPath: string; } | null; workingPath: string; logoPath: string; darkLogoPath?: string; assetsPath?: string; backgroundColor: string; darkBackgroundColor?: string; flavor: string; logoWidth: number; }) => { await generateSingle({ android, ios, workingPath, logoPath, backgroundColor, logoWidth, flavor, assetsPath, theme: "light", }); if (darkLogoPath && darkBackgroundColor) { await generateSingle({ android, ios, workingPath, logoPath: darkLogoPath, backgroundColor: darkBackgroundColor, logoWidth, flavor, assetsPath, theme: "dark", }); } if (ios) { createIosAssets({ projectPath: ios.projectPath, workingPath, includeDarkLogo: !!darkLogoPath, lightBackgroundColor: backgroundColor, darkBackgroundColor: darkBackgroundColor, }); } log.text(` ${pc.blue("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓")} ${pc.blue("┃")} 💖 ${pc.bold( "Love this library? Consider sponsoring!", )} ${pc.blue("┃")} ${pc.blue("┃")} ${pc.underline( "https://github.com/sponsors/zoontek", )} ${pc.blue("┃")} ${pc.blue("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")} `); log.text( `✅ Done! Thanks for using ${pc.underline( "react-native-smooth-bootsplash", )}.`, ); }; const generateSingle = async ({ android, ios, workingPath, logoPath, backgroundColor, logoWidth, flavor, assetsPath, theme, }: { android: { sourceDir: string; appName: string; } | null; ios: { projectPath: string; } | null; workingPath: string; logoPath: string; assetsPath?: string; backgroundColor: string; flavor: string; logoWidth: number; theme: "light" | "dark"; }) => { if (!isValidHexadecimal(backgroundColor)) { log.error("--background-color value is not a valid hexadecimal color."); process.exit(1); } const logoFileName = theme === "light" ? lightLogoFileName : darkLogoFileName; const image = sharp(logoPath); const backgroundColorHex = toFullHexadecimal(backgroundColor); const { format } = await image.metadata(); if (format !== "png" && format !== "svg") { log.error("Input file is an unsupported image format"); process.exit(1); } const logoHeight = await image .clone() .resize(logoWidth) .toBuffer() .then((buffer) => sharp(buffer).metadata()) .then(({ height = 0 }) => height); const shouldSkipAndroid = logoWidth > 288 || logoHeight > 288; const logAbove288 = (dimension: "height" | "width") => { const message = `⚠️ Logo ${dimension} exceed 288dp. As it will be cropped by Android, we skip generation for this platform.`; log.warn(message); }; const logAbove192 = (dimension: "height" | "width") => { const message = `⚠️ Logo ${dimension} exceed 192dp. It might be cropped by Android.`; log.warn(message); }; if (logoWidth > 288) { logAbove288("width"); } else if (logoHeight > 288) { logAbove288("height"); } else if (logoWidth > 192) { logAbove192("width"); } else if (logoHeight > 192) { logAbove192("height"); } const logWrite = ( emoji: string, filePath: string, dimensions?: { width: number; height: number }, ) => log.text( `${emoji} ${path.relative(workingPath, filePath)}` + (dimensions != null ? ` (${dimensions.width}x${dimensions.height})` : ""), ); if (assetsPath && fs.existsSync(assetsPath)) { log.text(`\n ${pc.underline("Assets")}`); 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 fileName = `${logoFileName}${suffix}.png`; const filePath = path.resolve(assetsPath, fileName); return image .clone() .resize(logoWidth * ratio) .png({ quality: 100 }) .toFile(filePath) .then(({ width, height }) => { logWrite("✨", filePath, { width, height }); }); }), ); } if (android && !shouldSkipAndroid) { log.text(`\n ${pc.underline("Android")}`); const appPath = android.appName ? path.resolve(android.sourceDir, android.appName) : path.resolve(android.sourceDir); // @react-native-community/cli 2.x & 3.x support const resPath = path.resolve(appPath, "src", flavor, "res"); const valuesPath = path.resolve( resPath, theme === "light" ? "values" : "values-night", ); fs.ensureDirSync(valuesPath); const colorsXmlPath = path.resolve(valuesPath, "colors.xml"); const colorsXmlEntry = `${backgroundColorHex}`; if (fs.existsSync(colorsXmlPath)) { const colorsXml = fs.readFileSync(colorsXmlPath, "utf-8"); if (colorsXml.match(androidColorRegex)) { fs.writeFileSync( colorsXmlPath, colorsXml.replace(androidColorRegex, colorsXmlEntry), "utf-8", ); } else { fs.writeFileSync( colorsXmlPath, colorsXml.replace( /<\/resources>/g, ` ${colorsXmlEntry}\n`, ), "utf-8", ); } logWrite("✏️ ", colorsXmlPath); } else { fs.writeFileSync( colorsXmlPath, `\n ${colorsXmlEntry}\n\n`, "utf-8", ); logWrite("✨", colorsXmlPath); } await Promise.all( [ { ratio: 1, directory: "mipmap-mdpi" }, { ratio: 1.5, directory: "mipmap-hdpi" }, { ratio: 2, directory: "mipmap-xhdpi" }, { ratio: 3, directory: "mipmap-xxhdpi" }, { ratio: 4, directory: "mipmap-xxxhdpi" }, ].map(({ ratio, directory }) => { const fileName = `${logoFileName}.png`; const filePath = path.resolve(resPath, directory, fileName); // https://github.com/androidx/androidx/blob/androidx-main/core/core-splashscreen/src/main/res/values/dimens.xml#L22 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, }, }, }); return image .clone() .resize(logoWidth * ratio) .toBuffer() .then((input) => canvas .composite([{ input }]) .png({ quality: 100 }) .toFile(filePath), ) .then(() => { logWrite("✨", filePath, { width: canvasSize, height: canvasSize }); }); }), ); } if (ios) { log.text(`\n ${pc.underline("iOS")}`); const { projectPath } = ios; const imagesPath = path.resolve(projectPath, "Images.xcassets"); if (fs.existsSync(projectPath)) { const storyboardPath = path.resolve(projectPath, "BootSplash.storyboard"); fs.writeFileSync( storyboardPath, getStoryboard({ height: logoHeight, width: logoWidth, }), "utf-8", ); logWrite("✨", storyboardPath); } else { log.text( `No "${projectPath}" directory found. Skipping iOS storyboard generation…`, ); } if (fs.existsSync(imagesPath)) { const imageSetPath = path.resolve(imagesPath, xcassetName + ".imageset"); fs.ensureDirSync(imageSetPath); await Promise.all( [ { ratio: 1, suffix: "" }, { ratio: 2, suffix: "@2x" }, { ratio: 3, suffix: "@3x" }, ].map(({ ratio, suffix }) => { const fileName = `${logoFileName}${suffix}.png`; const filePath = path.resolve(imageSetPath, fileName); return image .clone() .resize(logoWidth * ratio) .png({ quality: 100 }) .toFile(filePath) .then(({ width, height }) => { logWrite("✨", filePath, { width, height }); }); }), ); } else { log.text( `No "${imagesPath}" directory found. Skipping iOS images generation…`, ); } } }; const createIosAssets = ({ projectPath, workingPath, includeDarkLogo, lightBackgroundColor, darkBackgroundColor, }: { projectPath: string; workingPath: string; includeDarkLogo: boolean; lightBackgroundColor: string; darkBackgroundColor?: string; }) => { const logWrite = ( emoji: string, filePath: string, dimensions?: { width: number; height: number }, ) => log.text( `${emoji} ${path.relative(workingPath, filePath)}` + (dimensions != null ? ` (${dimensions.width}x${dimensions.height})` : ""), ); const imagesPath = path.resolve(projectPath, "Images.xcassets"); if (fs.existsSync(imagesPath)) { const imageSetPath = path.resolve(imagesPath, xcassetName + ".imageset"); fs.ensureDirSync(imageSetPath); fs.writeFileSync( path.resolve(imageSetPath, "Contents.json"), getLogoContentsJson(includeDarkLogo), "utf-8", ); logWrite("✨", path.resolve(imageSetPath, "Contents.json")); const colorSetPath = path.resolve(imagesPath, colorAssetName + ".colorset"); fs.ensureDirSync(colorSetPath); fs.writeFileSync( path.resolve(colorSetPath, "Contents.json"), getColorsContentsJson(lightBackgroundColor, darkBackgroundColor), "utf-8", ); logWrite("✨", path.resolve(colorSetPath, "Contents.json")); } };