diff --git a/lib/prepare.js b/lib/prepare.js index 722fc6415a..e00e4bd305 100644 --- a/lib/prepare.js +++ b/lib/prepare.js @@ -363,58 +363,123 @@ 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.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) { @@ -426,18 +491,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; diff --git a/tests/spec/unit/prepare.spec.js b/tests/spec/unit/prepare.spec.js index 769a3afacd..77db98fa65 100644 --- a/tests/spec/unit/prepare.spec.js +++ b/tests/spec/unit/prepare.spec.js @@ -400,6 +400,151 @@ 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-60@2x.png': 'dummy.png' + })); + }); + + it('should handle a sized spotlight icon', () => { + const icons = [ + { src: 'dummy.png', height: 120, width: 120, target: 'spotlight' } + ]; + + const resMap = mapIconResources(icons, ''); + + expect(resMap).toEqual(jasmine.objectContaining({ + 'icon-40@3x.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');