Add mount device custom icon generation (#33)

This commit is contained in:
Manuel Rueda 2019-05-12 12:40:16 -04:00 committed by Sindre Sorhus
parent 8a77dd1f60
commit a428a90e55
8 changed files with 179 additions and 84 deletions

175
cli.js
View File

@ -7,6 +7,7 @@ const appdmg = require('appdmg');
const plist = require('plist'); const plist = require('plist');
const Ora = require('ora'); const Ora = require('ora');
const execa = require('execa'); const execa = require('execa');
const composeIcon = require('./compose-icon');
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
console.error('macOS only'); console.error('macOS only');
@ -56,94 +57,106 @@ try {
const appInfo = plist.parse(infoPlist); const appInfo = plist.parse(infoPlist);
const appName = appInfo.CFBundleDisplayName || appInfo.CFBundleName; const appName = appInfo.CFBundleDisplayName || appInfo.CFBundleName;
// `const appIconName = appInfo.CFBundleIconFile.replace(/\.icns/, ''); const appIconName = appInfo.CFBundleIconFile.replace(/\.icns/, '');
const dmgPath = path.join(destPath, `${appName} ${appInfo.CFBundleShortVersionString}.dmg`); const dmgPath = path.join(destPath, `${appName} ${appInfo.CFBundleShortVersionString}.dmg`);
const ora = new Ora('Creating DMG'); const ora = new Ora('Creating DMG');
ora.start(); ora.start();
if (cli.flags.overwrite) { async function init() {
try { if (cli.flags.overwrite) {
fs.unlinkSync(dmgPath); try {
} catch (_) {} fs.unlinkSync(dmgPath);
} catch (_) {}
}
ora.text = 'Creating icon';
const composedIconPath = await composeIcon(path.join(appPath, 'Contents/Resources', `${appIconName}.icns`));
const ee = appdmg({
target: dmgPath,
basepath: process.cwd(),
specification: {
title: appName,
icon: composedIconPath,
//
// Use transparent background and `background-color` option when this is fixed:
// https://github.com/LinusU/node-appdmg/issues/135
background: path.join(__dirname, 'assets/dmg-background.png'),
'icon-size': 160,
format: 'ULFO',
window: {
size: {
width: 660,
height: 400
}
},
contents: [
{
x: 180,
y: 170,
type: 'file',
path: appPath
},
{
x: 480,
y: 170,
type: 'link',
path: '/Applications'
}
]
}
});
ee.on('progress', info => {
if (info.type === 'step-begin') {
ora.text = info.title;
}
});
ee.on('finish', async () => {
try {
ora.text = 'Replacing DMG icon';
// Seticon is a native tool to change files icons (Source: https://github.com/sveinbjornt/osxiconutils)
await execa(path.join(__dirname, 'seticon'), [composedIconPath, dmgPath]);
ora.text = 'Code signing DMG';
let identity;
const {stdout} = await execa('security', ['find-identity', '-v', '-p', 'codesigning']);
if (stdout.includes('Developer ID Application:')) {
identity = 'Developer ID Application';
} else if (stdout.includes('Mac Developer:')) {
identity = 'Mac Developer';
} else {
const err = new Error();
err.stderr = 'No usable identity found';
throw err;
}
await execa('codesign', ['--sign', identity, dmgPath]);
const {stderr} = await execa('codesign', [dmgPath, '--display', '--verbose=2']);
const match = /^Authority=(.*)$/m.exec(stderr);
if (!match) {
ora.fail('Not code signed');
process.exit(1);
}
ora.info(`Code signing identity: ${match[1]}`).start();
ora.succeed('DMG created');
} catch (error) {
ora.fail(`Code signing failed. The DMG is fine, just not code signed.\n${error.stderr.trim()}`);
process.exit(2);
}
});
ee.on('error', error => {
ora.fail(error);
process.exit(1);
});
} }
const ee = appdmg({ init().catch(error => {
target: dmgPath,
basepath: process.cwd(),
specification: {
title: appName,
// Disabled because of #16
// icon: path.join(appPath, 'Contents/Resources', `${appIconName}.icns`),
//
// Use transparent background and `background-color` option when this is fixed:
// https://github.com/LinusU/node-appdmg/issues/135
background: path.join(__dirname, 'assets/dmg-background.png'),
'icon-size': 160,
format: 'ULFO',
window: {
size: {
width: 660,
height: 400
}
},
contents: [
{
x: 180,
y: 170,
type: 'file',
path: appPath
},
{
x: 480,
y: 170,
type: 'link',
path: '/Applications'
}
]
}
});
ee.on('progress', info => {
if (info.type === 'step-begin') {
ora.text = info.title;
}
});
ee.on('finish', async () => {
ora.text = 'Code signing DMG';
try {
let identity;
const {stdout} = await execa('security', ['find-identity', '-v', '-p', 'codesigning']);
if (stdout.includes('Developer ID Application:')) {
identity = 'Developer ID Application';
} else if (stdout.includes('Mac Developer:')) {
identity = 'Mac Developer';
} else {
const err = new Error();
err.stderr = 'No usable identity found';
throw err;
}
await execa('codesign', ['--sign', identity, dmgPath]);
const {stderr} = await execa('codesign', [dmgPath, '--display', '--verbose=2']);
const match = /^Authority=(.*)$/m.exec(stderr);
if (!match) {
ora.fail('Not code signed');
process.exit(1);
}
ora.info(`Code signing identity: ${match[1]}`).start();
ora.succeed('DMG created');
} catch (error) {
ora.fail(`Code signing failed. The DMG is fine, just not code signed.\n${error.stderr.trim()}`);
process.exit(2);
}
});
ee.on('error', error => {
ora.fail(error); ora.fail(error);
process.exit(1); process.exit(1);
}); });

64
compose-icon.js Normal file
View File

@ -0,0 +1,64 @@
const fs = require('fs');
const {promisify} = require('util');
const execa = require('execa');
const tempy = require('tempy');
const gm = require('gm').subClass({imageMagick: true});
const icns = require('icns-lib');
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
const filterMap = (map, filterFn) => Object.entries(map).filter(filterFn).reduce((out, [key, item]) => ({...out, [key]: item}), {});
// Drive icon from /System/Library/Extensions/IOStorageFamily.kext/Contents/Resources/Removable.icns
const baseDiskIconPath = `${__dirname}/disk-icon.icns`;
async function composeIcon(type, appIcon, mountIcon, composedIcon) {
mountIcon = gm(mountIcon);
appIcon = gm(appIcon);
const appIconSize = await promisify(appIcon.size.bind(appIcon))();
const mountIconSize = appIconSize;
// Change the perspective of the app icon to match the mount drive icon
appIcon = appIcon.out('-matte').out('-virtual-pixel', 'transparent').out('-distort', 'Perspective', `1,1 ${appIconSize.width * 0.08},1 ${appIconSize.width},1 ${appIconSize.width * 0.92},1 1,${appIconSize.height} 1,${appIconSize.height} ${appIconSize.width},${appIconSize.height} ${appIconSize.width},${appIconSize.height}`);
// Resize the app icon to fit it inside the mount icon, aspect ration should not be kept to create the perspective illution
appIcon = appIcon.resize(appIconSize.width / 1.7, appIconSize.height / 1.78, '!');
const tempAppIconPath = tempy.file({extension: 'png'});
await promisify(appIcon.write.bind(appIcon))(tempAppIconPath);
// Compose the two icons
const iconGravityFactor = mountIconSize.height * 0.155;
mountIcon = mountIcon.composite(tempAppIconPath).gravity('Center').geometry(`+0-${iconGravityFactor}`);
composedIcon[type] = await promisify(mountIcon.toBuffer.bind(mountIcon))();
}
const hasGm = async () => {
try {
await execa('gm', ['-version']);
return true;
} catch (error) {
if (error.code === 'ENOENT') {
return false;
}
throw error;
}
};
module.exports = async function (appIconPath) {
if (!await hasGm()) {
return baseDiskIconPath;
}
const baseDiskIcons = filterMap(icns.parse(await readFile(baseDiskIconPath)), ([key]) => icns.isImageType(key));
const appIcon = filterMap(icns.parse(await readFile(appIconPath)), ([key]) => icns.isImageType(key));
const composedIcon = {};
await Promise.all(Object.entries(appIcon).map(async ([type, icon]) => {
if (baseDiskIcons[type]) {
return composeIcon(type, icon, baseDiskIcons[type], composedIcon);
}
console.warn('There is no base image for this type', type);
}));
const tempComposedIcon = tempy.file({extension: 'icns'});
await writeFile(tempComposedIcon, icns.format(composedIcon));
return tempComposedIcon;
};

BIN
disk-icon.icns Normal file

Binary file not shown.

BIN
icon-example-app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
icon-example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -20,7 +20,9 @@
}, },
"files": [ "files": [
"cli.js", "cli.js",
"assets" "assets",
"disk-icon.icns",
"seticon"
], ],
"keywords": [ "keywords": [
"cli-app", "cli-app",
@ -38,13 +40,15 @@
"dependencies": { "dependencies": {
"appdmg": "^0.5.2", "appdmg": "^0.5.2",
"execa": "^1.0.0", "execa": "^1.0.0",
"gm": "^1.23.1",
"icns-lib": "^1.0.1",
"meow": "^5.0.0", "meow": "^5.0.0",
"ora": "^3.0.0", "ora": "^3.0.0",
"plist": "^3.0.1" "plist": "^3.0.1",
"tempy": "^0.2.1"
}, },
"devDependencies": { "devDependencies": {
"ava": "^0.25.0", "ava": "^0.25.0",
"tempy": "^0.2.1",
"xo": "^0.23.0" "xo": "^0.23.0"
} }
} }

View File

@ -47,6 +47,20 @@ It will try to code sign the DMG, but the DMG is still created and fine even if
<img src="screenshot-dmg.png" width="772"> <img src="screenshot-dmg.png" width="772">
### DMG Icon
[GraphicsMagick](http://www.graphicsmagick.org/) is required to create the DMG icon based on the application icon and macOS mounted device icon.
### Steps using Brew
```bash
brew install imagemagick
brew install graphicsmagick
```
### Icon Example
<img src="icon-example-app.png" width="300">
<img src="icon-example.png" width="300">
## License ## License

BIN
seticon Executable file

Binary file not shown.