UNPKG

@howincodes/expo-dynamic-app-icon

Version:
378 lines (377 loc) 16.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const config_plugins_1 = require("@expo/config-plugins"); const image_utils_1 = require("@expo/image-utils"); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const moduleRoot = path_1.default.join(__dirname, "..", ".."); const { getMainApplicationOrThrow, getMainActivityOrThrow } = config_plugins_1.AndroidConfig.Manifest; const ANDROID_FOLDER_PATH = ["app", "src", "main", "res"]; const ANDROID_FOLDER_NAMES = [ "mipmap-hdpi", "mipmap-mdpi", "mipmap-xhdpi", "mipmap-xxhdpi", "mipmap-xxxhdpi", ]; const ANDROID_SIZES = [162, 108, 216, 324, 432]; /** The default icon folder name to export to */ const IOS_ASSETS_FOLDER_NAME = "Images.xcassets"; /** * The default icon dimensions to export. * * @see https://developer.apple.com/design/human-interface-guidelines/app-icons#iOS-iPadOS-app-icon-sizes */ const IOS_ICON_DIMENSIONS = [ // iPhone, iPad, MacOS { scale: 1, size: 1024 }, ]; const withDynamicIcon = (config, props = {}) => { const icons = resolveIcons(props); const dimensions = resolveIconDimensions(config); config = withGenerateTypes(config, { icons }); // for ios config = withIconXcodeProject(config, { icons, dimensions }); config = withIconImages(config, { icons, dimensions }); // for android config = withIconAndroidManifest(config, { icons, dimensions }); config = withIconAndroidImages(config, { icons, dimensions }); return config; }; // ============================================================================= // TypeScript // ============================================================================= function withGenerateTypes(config, props) { const names = Object.keys(props.icons); const union = names.map((name) => `"${name}"`).join(" | ") || "string"; const unionType = `IconName: ${union}`; const buildFile = path_1.default.join(moduleRoot, "build", "types.d.ts"); const buildFileContent = fs_1.default.readFileSync(buildFile, "utf8"); const updatedContent = buildFileContent.replace(/IconName:\s.*/, unionType); fs_1.default.writeFileSync(buildFile, updatedContent); return config; } // ============================================================================= // Android // ============================================================================= const withIconAndroidManifest = (config, { icons }) => { return (0, config_plugins_1.withAndroidManifest)(config, (config) => { const mainApplication = getMainApplicationOrThrow(config.modResults); const mainActivity = getMainActivityOrThrow(config.modResults); const iconNamePrefix = `${config.android.package}.MainActivity`; const iconNames = Object.keys(icons); function addIconActivityAlias(config) { return [ ...config, ...iconNames.map((iconName) => ({ $: { "android:name": `${iconNamePrefix}${iconName}`, "android:enabled": "false", "android:exported": "true", "android:icon": `@mipmap/${iconName}`, "android:targetActivity": ".MainActivity", "android:roundIcon": `@mipmap/${iconName}_round`, }, "intent-filter": [ ...(mainActivity["intent-filter"] || [ { action: [ { $: { "android:name": "android.intent.action.MAIN" } }, ], category: [ { $: { "android:name": "android.intent.category.LAUNCHER" } }, ], }, ]), ], })), ]; } function removeIconActivityAlias(config) { return config.filter((activityAlias) => !activityAlias.$["android:name"].startsWith(iconNamePrefix)); } mainApplication["activity-alias"] = removeIconActivityAlias(mainApplication["activity-alias"] || []); mainApplication["activity-alias"] = addIconActivityAlias(mainApplication["activity-alias"] || []); return config; }); }; const withIconAndroidImages = (config, { icons }) => { return (0, config_plugins_1.withDangerousMod)(config, [ "android", async (config) => { const androidResPath = path_1.default.join(config.modRequest.platformProjectRoot, ...ANDROID_FOLDER_PATH); const removeIconRes = async () => { for (let i = 0; ANDROID_FOLDER_NAMES.length > i; i += 1) { const folder = path_1.default.join(androidResPath, ANDROID_FOLDER_NAMES[i]); const files = await fs_1.default.promises.readdir(folder).catch(() => []); for (let j = 0; files.length > j; j += 1) { if (!files[j].startsWith("ic_launcher")) { await fs_1.default.promises .rm(path_1.default.join(folder, files[j]), { force: true }) .catch(() => null); } } } }; const addIconRes = async () => { for (let i = 0; ANDROID_FOLDER_NAMES.length > i; i += 1) { const size = ANDROID_SIZES[i]; const outputPath = path_1.default.join(androidResPath, ANDROID_FOLDER_NAMES[i]); // square ones for (const [name, { android }] of Object.entries(icons)) { if (!android) continue; const fileName = `${name}.png`; const { source } = await (0, image_utils_1.generateImageAsync)({ projectRoot: config.modRequest.projectRoot, cacheType: `expo-dynamic-app-icon-${size}`, }, { name: fileName, src: android, removeTransparency: true, backgroundColor: "#ffffff", resizeMode: "cover", width: size, height: size, }); await fs_1.default.promises.writeFile(path_1.default.join(outputPath, fileName), source); } // round ones for (const [name, { android }] of Object.entries(icons)) { if (!android) continue; const fileName = `${name}_round.png`; const { source } = await (0, image_utils_1.generateImageAsync)({ projectRoot: config.modRequest.projectRoot, cacheType: `expo-dynamic-app-icon-round-${size}`, }, { name: fileName, src: android, removeTransparency: true, backgroundColor: "#ffffff", resizeMode: "cover", width: size, height: size, borderRadius: size / 2, }); await fs_1.default.promises.writeFile(path_1.default.join(outputPath, fileName), source); } } }; await removeIconRes(); await addIconRes(); return config; }, ]); }; // ============================================================================= // iOS // ============================================================================= const withIconXcodeProject = (config, { icons, dimensions }) => { return (0, config_plugins_1.withXcodeProject)(config, async (config) => { const project = config.modResults; // Remove old settings const configurations = project.hash.project.objects["XCBuildConfiguration"]; for (const id of Object.keys(configurations)) { const configuration = project.hash.project.objects["XCBuildConfiguration"][id]; if (typeof configuration !== "object") continue; const buildSettings = configuration.buildSettings; delete buildSettings["ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES"]; delete buildSettings["ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS"]; delete buildSettings["ASSETCATALOG_COMPILER_APPICON_NAME"]; project.hash.project.objects["XCBuildConfiguration"][id].buildSettings = buildSettings; } // Add new settings for (const id of Object.keys(configurations)) { const configuration = project.hash.project.objects["XCBuildConfiguration"][id]; if (typeof configuration !== "object") continue; const buildSettings = configuration.buildSettings; // Include all AppIcon assets buildSettings["ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS"] = "YES"; // Include all alternate AppIcon names const names = []; await iterateIconsAndDimensionsAsync({ icons, dimensions }, async (key) => { const iconName = getIconName(key); names.push(iconName); }); buildSettings["ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES"] = JSON.stringify(names.join(" ")); // Include default icon buildSettings["ASSETCATALOG_COMPILER_APPICON_NAME"] = "AppIcon"; project.hash.project.objects["XCBuildConfiguration"][id].buildSettings = buildSettings; } return config; }); }; const withIconImages = (config, { icons, dimensions }) => { return (0, config_plugins_1.withDangerousMod)(config, [ "ios", async (config) => { const iosRoot = path_1.default.join(config.modRequest.platformProjectRoot, config.modRequest.projectName); await iterateIconsAndDimensionsAsync({ icons, dimensions }, async (key, { icon, dimension }) => { if (!icon.ios) return; // Clean the old AppIcon-*.appiconset const iconsetPath = path_1.default.join(IOS_ASSETS_FOLDER_NAME, `${getIconName(key)}.appiconset`); const outputIconsetPath = path_1.default.join(iosRoot, iconsetPath); await fs_1.default.promises .rm(outputIconsetPath, { recursive: true, force: true, }) .catch(() => null); await fs_1.default.promises.mkdir(outputIconsetPath, { recursive: true }); // Generate the Contents.json file const contents = generateIconsetContents(icon.ios, key, dimension); const outputContentsPath = path_1.default.join(outputIconsetPath, "Contents.json"); await fs_1.default.promises.writeFile(outputContentsPath, JSON.stringify(contents, null, 2)); const images = typeof icon.ios === "string" ? { light: icon.ios } : icon.ios; // Generate the assets for each variant for (const [variant, icon] of Object.entries(images)) { const iconFileName = getIconAssetFileName(key, variant, dimension); const isTransparent = variant === "dark"; const { source } = await (0, image_utils_1.generateImageAsync)({ projectRoot: config.modRequest.projectRoot, cacheType: `expo-dynamic-app-icon-${dimension.width}-${dimension.height}`, }, { name: iconFileName, src: icon, removeTransparency: !isTransparent, backgroundColor: isTransparent ? "transparent" : "#ffffff", resizeMode: "cover", width: dimension.width, height: dimension.height, }); const outputAssetPath = path_1.default.join(outputIconsetPath, iconFileName); await fs_1.default.promises.writeFile(outputAssetPath, source); } }); return config; }, ]); }; /** Resolve and sanitize the icon set from config plugin props. */ function resolveIcons(props) { let icons = {}; if (Array.isArray(props)) { icons = props.reduce((prev, curr, i) => ({ ...prev, [i]: { image: curr } }), {}); } else if (props) { icons = props; } return icons; } /** Resolve the required icon dimension/target based on the app config. */ function resolveIconDimensions(config) { const targets = []; if (config.ios?.supportsTablet) { targets.push("ipad"); } return IOS_ICON_DIMENSIONS.filter(({ target }) => !target || targets.includes(target)).map((dimension) => ({ ...dimension, target: dimension.target ?? null, width: dimension.width ?? dimension.size * dimension.scale, height: dimension.height ?? dimension.size * dimension.scale, })); } /** Get the icon name, used to refer to the icon from within the plist */ function getIconName(name) { return `AppIcon-${name}`; } /** Get the icon asset file name */ function getIconAssetFileName(key, variant, dimension) { const name = `${getIconName(key)}-${variant}`; const size = `${dimension.size}x${dimension.size}@${dimension.scale}x`; return `${name}-${size}.png`; } /** Generate the Contents.json for an icon set */ function generateIconsetContents(iconset, key, dimension) { const lightFileName = getIconAssetFileName(key, "light", dimension); const images = [ { filename: lightFileName, idiom: "universal", platform: "ios", size: `${dimension.size}x${dimension.size}`, }, ]; if (typeof iconset === "object" && iconset.dark) { const darkFileName = getIconAssetFileName(key, "dark", dimension); images.push({ filename: darkFileName, idiom: "universal", platform: "ios", size: `${dimension.size}x${dimension.size}`, appearances: [ { appearance: "luminosity", value: "dark", }, ], }); } else { images.push({ idiom: "universal", platform: "ios", size: `${dimension.size}x${dimension.size}`, appearances: [ { appearance: "luminosity", value: "dark", }, ], }); } if (typeof iconset === "object" && iconset.tinted) { const tintedFileName = getIconAssetFileName(key, "tinted", dimension); images.push({ filename: tintedFileName, idiom: "universal", platform: "ios", size: `${dimension.size}x${dimension.size}`, appearances: [ { appearance: "luminosity", value: "tinted", }, ], }); } else { images.push({ idiom: "universal", platform: "ios", size: `${dimension.size}x${dimension.size}`, appearances: [ { appearance: "luminosity", value: "tinted", }, ], }); } return { images, info: { version: 1, author: "expo", }, }; } /** Iterate all combinations of icons and dimensions to export */ async function iterateIconsAndDimensionsAsync({ icons, dimensions }, callback) { for (const [iconKey, icon] of Object.entries(icons)) { for (const dimension of dimensions) { await callback(iconKey, { icon, dimension }); } } } exports.default = withDynamicIcon;