Skip to content

Commit

Permalink
feat(icons)!: Support a single 1024⨉1024 icon, but also more complex …
Browse files Browse the repository at this point in the history
…customizations (#1465)

* feat(icons)!: Use a single 1024⨉1024 icon

Closes GH-1462.

* feat!(icons): Allow a lot more control over icon assignment

Ironically, this also allows most people to drastically simplify their
icons by only providing a single 1024⨉1024 image with no special
attributes.

Closes GH-592.
Closes GH-623.
Closes GH-657.
Closes GH-658.
Closes GH-1019.
Closes GH-1233.
Closes GH-1387.

* fix: Only support tinted/dark icons if using Xcode 16+

Xcode 15 doesn't understand the appearance variants, and if variants are
specified then it thinks there are 3 images assigned to the same slot
and ends up picking one at random and we don't want that.

---------

Co-authored-by: jcesarmobile <jcesarmobile@gmail.com>
  • Loading branch information
dpogue and jcesarmobile authored Aug 21, 2024
1 parent 3a426fd commit 3ce3a7e
Show file tree
Hide file tree
Showing 72 changed files with 999 additions and 467 deletions.
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

0 comments on commit 3ce3a7e

Please sign in to comment.