Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(icons)!: Support a single 1024⨉1024 icon, but also more complex customizations #1465

Merged
merged 3 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 213 additions & 68 deletions lib/prepare.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,21 @@ const projectFile = require('./projectFile');
const Podfile = require('./Podfile').Podfile;
const check_reqs = require('./check_reqs');
const PlatformConfigParser = require('./PlatformConfigParser');
const versions = require('./versions');

// launch storyboard and related constants
const IMAGESET_COMPACT_SIZE_CLASS = 'compact';
const CDV_ANY_SIZE_CLASS = 'any';

const ASSUMED_XCODE_VERSION = '15.0.0';
function checkOrAssumeXcodeVersion () {
if (process.platform === 'darwin') {
return versions.get_apple_xcode_version();
} else {
return Promise.resolve(ASSUMED_XCODE_VERSION);
}
}

module.exports.prepare = function (cordovaProject, options) {
const platformJson = PlatformJson.load(this.locations.root, 'ios');
const munger = new PlatformMunger('ios', this.locations.root, platformJson, new PluginInfoProvider());
Expand All @@ -55,15 +65,11 @@ module.exports.prepare = function (cordovaProject, options) {
return updateWww(cordovaProject, this.locations)
// update project according to config.xml changes.
.then(() => updateProject(this._config, this.locations))
.then(() => {
updateIcons(cordovaProject, this.locations);
updateLaunchStoryboardImages(cordovaProject, this.locations);
updateBackgroundColor(cordovaProject, this.locations);
updateFileResources(cordovaProject, this.locations);
})
.then(() => {
alertDeprecatedPreference(this._config);
})
.then(() => updateIcons(cordovaProject, this.locations))
.then(() => updateLaunchStoryboardImages(cordovaProject, this.locations))
.then(() => updateBackgroundColor(cordovaProject, this.locations))
.then(() => updateFileResources(cordovaProject, this.locations))
.then(() => alertDeprecatedPreference(this._config))
.then(() => {
events.emit('verbose', 'Prepared iOS project successfully');
});
Expand All @@ -83,13 +89,12 @@ module.exports.clean = function (options) {

const projectConfig = new ConfigParser(this.locations.configXml);

return Promise.resolve().then(() => {
cleanWww(projectRoot, this.locations);
cleanIcons(projectRoot, projectConfig, this.locations);
cleanLaunchStoryboardImages(projectRoot, projectConfig, this.locations);
cleanBackgroundColor(projectRoot, projectConfig, this.locations);
cleanFileResources(projectRoot, projectConfig, this.locations);
});
return Promise.resolve()
.then(() => cleanWww(projectRoot, this.locations))
.then(() => cleanIcons(projectRoot, projectConfig, this.locations))
.then(() => cleanLaunchStoryboardImages(projectRoot, projectConfig, this.locations))
.then(() => cleanBackgroundColor(projectRoot, projectConfig, this.locations))
.then(() => cleanFileResources(projectRoot, projectConfig, this.locations));
};

/**
Expand Down Expand Up @@ -362,92 +367,232 @@ function handleBuildSettings (platformConfig, locations, infoPlist) {
return Promise.resolve();
}

function mapIconResources (icons, iconsDir) {
// See https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/MobileHIG/IconMatrix.html
// for launch images sizes reference.
function mapIconResources (icons, iconsDir, xcodeversion) {
// Ref: https://developer.apple.com/design/human-interface-guidelines/app-icons
// These are ordered according to how Xcode puts them in the Contents.json file
const platformIcons = [
{ dest: 'icon-20.png', width: 20, height: 20 },
// iOS fallback icon sizes
{ dest: 'icon-20@2x.png', width: 40, height: 40 },
{ dest: 'icon-20@3x.png', width: 60, height: 60 },
{ dest: 'icon-40.png', width: 40, height: 40 },
{ dest: 'icon-40@2x.png', width: 80, height: 80 },
{ dest: 'icon-50.png', width: 50, height: 50 },
{ dest: 'icon-50@2x.png', width: 100, height: 100 },
{ dest: 'icon-29@2x.png', width: 58, height: 58 },
{ dest: 'icon-29@3x.png', width: 87, height: 87 },
{ dest: 'icon-38@2x.png', width: 76, height: 76 },
{ dest: 'icon-38@3x.png', width: 114, height: 114 },
{ dest: 'icon-40@2x.png', width: 80, height: 80, target: 'spotlight' },
{ dest: 'icon-40@3x.png', width: 120, height: 120, target: 'spotlight' },
{ dest: 'icon-60@2x.png', width: 120, height: 120 },
{ dest: 'icon-60@3x.png', width: 180, height: 180 },
{ dest: 'icon-72.png', width: 72, height: 72 },
{ dest: 'icon-72@2x.png', width: 144, height: 144 },
{ dest: 'icon-76.png', width: 76, height: 76 },
{ dest: 'icon-64@2x.png', width: 128, height: 128 },
{ dest: 'icon-64@3x.png', width: 192, height: 192 },
{ dest: 'icon-68@2x.png', width: 136, height: 136 },
{ dest: 'icon-76@2x.png', width: 152, height: 152 },
{ dest: 'icon-83.5@2x.png', width: 167, height: 167 },
{ dest: 'icon-1024.png', width: 1024, height: 1024 },
{ dest: 'icon-29.png', width: 29, height: 29 },
{ dest: 'icon-29@2x.png', width: 58, height: 58 },
{ dest: 'icon-29@3x.png', width: 87, height: 87 },
{ dest: 'icon.png', width: 57, height: 57 },
{ dest: 'icon@2x.png', width: 114, height: 114 },
{ dest: 'icon-24@2x.png', width: 48, height: 48 },
{ dest: 'icon-27.5@2x.png', width: 55, height: 55 },
{ dest: 'icon-44@2x.png', width: 88, height: 88 },
{ dest: 'icon-86@2x.png', width: 172, height: 172 },
{ dest: 'icon-98@2x.png', width: 196, height: 196 }

// Default iOS icon
{ dest: 'icon.png', width: 1024, height: 1024, useDefault: true },

// macOS icon sizes
{ dest: 'mac-16.png', width: 16, height: 16, target: 'mac' },
{ dest: 'mac-16@2x.png', width: 32, height: 32, target: 'mac' },
{ dest: 'mac-32.png', width: 32, height: 32, target: 'mac' },
{ dest: 'mac-32@2x.png', width: 64, height: 64, target: 'mac' },
{ dest: 'mac-128.png', width: 128, height: 128, target: 'mac' },
{ dest: 'mac-128@2x.png', width: 256, height: 256, target: 'mac' },
{ dest: 'mac-256.png', width: 256, height: 256, target: 'mac' },
{ dest: 'mac-256@2x.png', width: 512, height: 512, target: 'mac' },
{ dest: 'mac-512.png', width: 512, height: 512, target: 'mac' },
{ dest: 'mac-512@2x.png', width: 1024, height: 1024, target: 'mac' },

// WatchOS fallback icon sizes
{ dest: 'watchos-22@2x.png', width: 44, height: 44, target: 'watchos' },
{ dest: 'watchos-24@2x.png', width: 48, height: 48, target: 'watchos' },
{ dest: 'watchos-27.5@2x.png', width: 55, height: 55, target: 'watchos' },
{ dest: 'watchos-29@2x.png', width: 58, height: 58, target: 'watchos' },
{ dest: 'watchos-30@2x.png', width: 60, height: 60, target: 'watchos' },
{ dest: 'watchos-32@2x.png', width: 64, height: 64, target: 'watchos' },
{ dest: 'watchos-33@2x.png', width: 66, height: 66, target: 'watchos' },
{ dest: 'watchos-40@2x.png', width: 80, height: 80, target: 'watchos' },
{ dest: 'watchos-43.5@2x.png', width: 87, height: 87, target: 'watchos' },
{ dest: 'watchos-44@2x.png', width: 88, height: 88, target: 'watchos' },
{ dest: 'watchos-46@2x.png', width: 92, height: 92, target: 'watchos' },
{ dest: 'watchos-50@2x.png', width: 100, height: 100, target: 'watchos' },
{ dest: 'watchos-51@2x.png', width: 102, height: 102, target: 'watchos' },
{ dest: 'watchos-54@2x.png', width: 108, height: 108, target: 'watchos' },
{ dest: 'watchos-86@2x.png', width: 172, height: 172, target: 'watchos' },
{ dest: 'watchos-98@2x.png', width: 196, height: 196, target: 'watchos' },
{ dest: 'watchos-108@2x.png', width: 216, height: 216, target: 'watchos' },
{ dest: 'watchos-117@2x.png', width: 234, height: 234, target: 'watchos' },
{ dest: 'watchos-129@2x.png', width: 258, height: 258, target: 'watchos' },

// Allow customizing the watchOS icon with target="watchos"
// This falls back to using the iOS app icon by default
{ dest: 'watchos.png', width: 1024, height: 1024, target: 'watchos', useDefault: true }
];

const pathMap = {};

// We can only support dark mode and tinted icons with Xcode 16
const isAtLeastXcode16 = versions.compareVersions(xcodeversion, '16.0.0') >= 0;

function getDefaultIconForTarget (target) {
const def = icons.filter(res => !res.width && !res.height && !res.target).pop();

if (target) {
return icons
.filter(res => res.target === target)
.filter(res => !res.width && !res.height)
.pop() || def;
}

return def;
}

function getIconBySizeAndTarget (width, height, target) {
return icons
.filter(res => res.target === target)
.find(res =>
(res.width || res.height) &&
(!res.width || (width === res.width)) &&
(!res.height || (height === res.height))
) || null;
}

platformIcons.forEach(item => {
const icon = icons.getBySize(item.width, item.height) || icons.getDefault();
const dest = path.join(iconsDir, item.dest);
let icon = getIconBySizeAndTarget(item.width, item.height, item.target);

if (!icon && item.target === 'spotlight') {
// Fall back to a non-targeted icon
icon = getIconBySizeAndTarget(item.width, item.height);
}

if (!icon && item.useDefault) {
if (item.target) {
icon = getIconBySizeAndTarget(item.width, item.height);
}

const defaultIcon = getDefaultIconForTarget(item.target);
if (!icon && defaultIcon) {
icon = defaultIcon;
}
}

if (icon) {
const target = path.join(iconsDir, item.dest);
pathMap[target] = icon.src;
if (icon.src) {
pathMap[dest] = icon.src;
}

// Only iOS has dark/tinted icon variants
if (isAtLeastXcode16 && (!item.target || item.target === 'spotlight')) {
if (icon.foreground) {
pathMap[dest.replace('.png', '-dark.png')] = icon.foreground;
}

if (icon.monochrome) {
pathMap[dest.replace('.png', '-tinted.png')] = icon.monochrome;
}
}
}
});

return pathMap;
}

function getIconsDir (projectRoot, platformProjDir) {
let iconsDir;
const xcassetsExists = folderExists(path.join(projectRoot, platformProjDir, 'Assets.xcassets'));
function generateAppIconContentsJson (resourceMap) {
const contentsJSON = {
images: [],
info: {
author: 'xcode',
version: 1
}
};

if (xcassetsExists) {
iconsDir = path.join(platformProjDir, 'Assets.xcassets', 'AppIcon.appiconset');
} else {
iconsDir = path.join(platformProjDir, 'Resources', 'icons');
}
Object.keys(resourceMap).forEach(res => {
const [filename, platform, size, scale, variant] = path.basename(res).match(/([A-Za-z]+)(?:-([0-9.]+)(?:@([0-9.]x))?)?(?:-([a-z]+))?\.png/);

return iconsDir;
const entry = {
filename,
idiom: 'universal',
platform: (platform === 'icon') ? 'ios' : platform,
size: `${size ?? 1024}x${size ?? 1024}`
};

if (scale) {
entry.scale = scale;
}

if (variant) {
entry.appearances = [
{
appearance: 'luminosity',
value: variant
}
];
}

contentsJSON.images.push(entry);
});

return contentsJSON;
}

function updateIcons (cordovaProject, locations) {
const icons = cordovaProject.projectConfig.getIcons('ios');

if (icons.length === 0) {
events.emit('verbose', 'This app does not have icons defined');
return;
return Promise.resolve();
}

const platformProjDir = path.relative(cordovaProject.root, locations.xcodeCordovaProj);
const iconsDir = getIconsDir(cordovaProject.root, platformProjDir);
const resourceMap = mapIconResources(icons, iconsDir);
events.emit('verbose', `Updating icons at ${iconsDir}`);
FileUpdater.updatePaths(
resourceMap, { rootDir: cordovaProject.root }, logFileOp);
const iconsDir = path.join(platformProjDir, 'Assets.xcassets', 'AppIcon.appiconset');

return checkOrAssumeXcodeVersion()
.then((xcodeversion) => {
const resourceMap = mapIconResources(icons, iconsDir, xcodeversion);
events.emit('verbose', `Updating icons at ${iconsDir}`);
FileUpdater.updatePaths(
resourceMap, { rootDir: cordovaProject.root }, logFileOp);

// Now we need to update the AppIcon.appiconset/Contents.json file
const contentsJSON = generateAppIconContentsJson(resourceMap);

events.emit('verbose', 'Updating App Icon image set contents.json');
fs.writeFileSync(path.join(cordovaProject.root, iconsDir, 'Contents.json'), JSON.stringify(contentsJSON, null, 2));
});
}

function cleanIcons (projectRoot, projectConfig, locations) {
const icons = projectConfig.getIcons('ios');
if (icons.length > 0) {
const platformProjDir = path.relative(projectRoot, locations.xcodeCordovaProj);
const iconsDir = getIconsDir(projectRoot, platformProjDir);
const resourceMap = mapIconResources(icons, iconsDir);
Object.keys(resourceMap).forEach(targetIconPath => {
resourceMap[targetIconPath] = null;
});
events.emit('verbose', `Cleaning icons at ${iconsDir}`);

// Source paths are removed from the map, so updatePaths() will delete the target files.
FileUpdater.updatePaths(
resourceMap, { rootDir: projectRoot, all: true }, logFileOp);
if (icons.length === 0) {
return Promise.resolve();
}

const platformProjDir = path.relative(projectRoot, locations.xcodeCordovaProj);
const iconsDir = path.join(platformProjDir, 'Assets.xcassets', 'AppIcon.appiconset');

return checkOrAssumeXcodeVersion()
.then((xcodeversion) => {
const resourceMap = mapIconResources(icons, iconsDir, xcodeversion);
Object.keys(resourceMap).forEach(targetIconPath => {
resourceMap[targetIconPath] = null;
});
events.emit('verbose', `Cleaning icons at ${iconsDir}`);

// Source paths are removed from the map, so updatePaths() will delete the target files.
FileUpdater.updatePaths(
resourceMap, { rootDir: projectRoot, all: true }, logFileOp);

const contentsJSON = generateAppIconContentsJson(resourceMap);

// delete filename from contents.json
contentsJSON.images.forEach(image => {
image.filename = undefined;
});

events.emit('verbose', 'Updating App Icon image set contents.json');
fs.writeFileSync(path.join(projectRoot, iconsDir, 'Contents.json'), JSON.stringify(contentsJSON, null, 2));
});
}

/**
Expand Down
Loading