Skip to content

Commit

Permalink
feat(icons): Allow a lot more control over icon assignment
Browse files Browse the repository at this point in the history
Ironically, this also allows most people to drastically simplify their
icons by only providing a single 1024⨉1024 image with no special
attributes.

Closes apacheGH-592.
Closes apacheGH-623.
Closes apacheGH-657.
Closes apacheGH-658.
Closes apacheGH-1019.
Closes apacheGH-1233.
Closes apacheGH-1387.
  • Loading branch information
dpogue committed Aug 17, 2024
1 parent efd013e commit 6688e7d
Show file tree
Hide file tree
Showing 2 changed files with 298 additions and 39 deletions.
189 changes: 150 additions & 39 deletions lib/prepare.js
Original file line number Diff line number Diff line change
Expand Up @@ -363,58 +363,128 @@ function handleBuildSettings (platformConfig, locations, infoPlist) {
}

function mapIconResources (icons, iconsDir) {
// See https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/MobileHIG/IconMatrix.html
// for launch images sizes reference.
const platformIcons = [
{ dest: 'icon-20.png', width: 20, height: 20 },
{ dest: 'icon.png', width: 1024, height: 1024, useDefault: true },

// 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 },

// 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: 72, height: 72 },
{ dest: 'icon-38@3x.png', width: 144, height: 144 },
{ 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: 196, height: 196 },
{ 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 }

// 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' },

// 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' }
];

const pathMap = {};
platformIcons.forEach(item => {
const icon = icons.getBySize(item.width, item.height) || icons.getDefault();
if (icon) {
const target = path.join(iconsDir, item.dest);
pathMap[target] = icon.src;

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 pathMap;
}

function getIconsDir (projectRoot, platformProjDir) {
let iconsDir;
const xcassetsExists = folderExists(path.join(projectRoot, platformProjDir, 'Assets.xcassets'));
return def;
}

if (xcassetsExists) {
iconsDir = path.join(platformProjDir, 'Assets.xcassets', 'AppIcon.appiconset');
} else {
iconsDir = path.join(platformProjDir, 'Resources', 'icons');
function getIconBySizeAndTarget (width, height, target) {
return icons
.filter(res => !target || (res.target === target))
.find(res =>
(res.width || res.height) &&
(!res.width || (width === res.width)) &&
(!res.height || (height === res.height))
) || null;
}

return iconsDir;
platformIcons.forEach(item => {
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) {
if (icon.src) {
pathMap[dest] = icon.src;
}

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

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

return pathMap;
}

function updateIcons (cordovaProject, locations) {
Expand All @@ -426,18 +496,59 @@ function updateIcons (cordovaProject, locations) {
}

const platformProjDir = path.relative(cordovaProject.root, locations.xcodeCordovaProj);
const iconsDir = getIconsDir(cordovaProject.root, platformProjDir);
const iconsDir = path.join(platformProjDir, 'Assets.xcassets', 'AppIcon.appiconset');
const resourceMap = mapIconResources(icons, iconsDir);
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 = {
images: [],
info: {
author: 'Xcode',
version: 1
}
};

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/);

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);
});

// TODO: Remove debugging
console.log(JSON.stringify(contentsJSON));

events.emit('verbose', 'Updating App Icon image set contents.json');
fs.writeFileSync(path.join(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 iconsDir = path.join(platformProjDir, 'Assets.xcassets', 'AppIcon.appiconset');
const resourceMap = mapIconResources(icons, iconsDir);
Object.keys(resourceMap).forEach(targetIconPath => {
resourceMap[targetIconPath] = null;
Expand Down
148 changes: 148 additions & 0 deletions tests/spec/unit/prepare.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,154 @@ describe('prepare', () => {
});
});

describe('App Icon handling', () => {
const mapIconResources = prepare.__get__('mapIconResources');

describe('#mapIconResources', () => {
it('should handle a default icon', () => {
const icons = [
{ src: 'dummy.png' }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual(jasmine.objectContaining({
'icon.png': 'dummy.png',
'watchos.png': 'dummy.png'
}));
});

it('should handle a default icon for a watchos target', () => {
const icons = [
{ src: 'dummy.png' },
{ src: 'dummy-watch.png', target: 'watchos' }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual(jasmine.objectContaining({
'icon.png': 'dummy.png',
'watchos.png': 'dummy-watch.png'
}));
});

it('should handle default icon variants', () => {
const icons = [
{ src: 'dummy.png', monochrome: 'dummy-tint.png', foreground: 'dummy-dark.png' }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual(jasmine.objectContaining({
'icon.png': 'dummy.png',
'icon-dark.png': 'dummy-dark.png',
'icon-tinted.png': 'dummy-tint.png',
'watchos.png': 'dummy.png'
}));
});

it('should handle a single sized icon', () => {
const icons = [
{ src: 'dummy.png', height: 1024, width: 1024 }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual(jasmine.objectContaining({
'icon.png': 'dummy.png',
'watchos.png': 'dummy.png'
}));
});

it('should handle a single sized icon for watchos target', () => {
const icons = [
{ src: 'dummy.png', height: 1024, width: 1024, target: 'watchos' }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual(jasmine.objectContaining({
'watchos.png': 'dummy.png'
}));
});

it('should handle a sized icon', () => {
const icons = [
{ src: 'dummy.png', height: 120, width: 120 }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual(jasmine.objectContaining({
'icon-40@3x.png': 'dummy.png',
'icon-60@2x.png': 'dummy.png'
}));
});

it('should handle a sized spotlight icon', () => {
const icons = [
{ src: 'dummy.png', height: 120, width: 120 },
{ src: 'dummy-spot.png', height: 120, width: 120, target: 'spotlight' }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual(jasmine.objectContaining({
'icon-40@3x.png': 'dummy-spot.png',
'icon-60@2x.png': 'dummy.png'
}));
});

it('should handle sized icon variants', () => {
const icons = [
{ src: 'dummy.png', height: 72, width: 72, monochrome: 'dummy-tint.png', foreground: 'dummy-dark.png' }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual(jasmine.objectContaining({
'icon-38@2x.png': 'dummy.png',
'icon-38@2x-dark.png': 'dummy-dark.png',
'icon-38@2x-tinted.png': 'dummy-tint.png'
}));
});

it('should ignore sized watchos icons without a target', () => {
const icons = [
{ src: 'dummy.png', height: 216, width: 216 }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual({});
});

it('should handle a sized macOS icon', () => {
const icons = [
{ src: 'dummy.png', height: 256, width: 256, target: 'mac' }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual(jasmine.objectContaining({
'mac-128@2x.png': 'dummy.png',
'mac-256.png': 'dummy.png'
}));
});

it('should ignore tinted icons for non-iOS targets', () => {
const icons = [
{ monochrome: 'dummy-tint.png', height: 256, width: 256, target: 'mac' },
{ foreground: 'dummy-dark.png', height: 216, width: 216, target: 'watchos' }
];

const resMap = mapIconResources(icons, '');

expect(resMap).toEqual({});
});
});
});

describe('colorPreferenceToComponents', () => {
const colorPreferenceToComponents = prepare.__get__('colorPreferenceToComponents');

Expand Down

0 comments on commit 6688e7d

Please sign in to comment.