├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── assets ├── dmg-background.png └── dmg-background@2x.png ├── base.r ├── cli.js ├── compose-icon.js ├── disk-icon.icns ├── fixtures ├── Fixture-no-icon.app │ └── Contents │ │ ├── Info.plist │ │ ├── MacOS │ │ └── fixture │ │ ├── PkgInfo │ │ └── Resources │ │ └── English.lproj │ │ ├── InfoPlist.strings │ │ └── MainMenu.nib ├── Fixture-with-binary-plist.app │ └── Contents │ │ ├── Info.plist │ │ ├── MacOS │ │ └── fixture │ │ ├── PkgInfo │ │ └── Resources │ │ ├── English.lproj │ │ ├── InfoPlist.strings │ │ └── MainMenu.nib │ │ └── app.icns └── Fixture.app │ └── Contents │ ├── Info.plist │ ├── MacOS │ └── fixture │ ├── PkgInfo │ └── Resources │ ├── English.lproj │ ├── InfoPlist.strings │ └── MainMenu.nib │ └── app.icns ├── icon-example-app.png ├── icon-example.png ├── license ├── package.json ├── readme.md ├── screenshot-cli.gif ├── screenshot-dmg.png ├── sla.js ├── stuff └── dmg-background.sketch └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: macos-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 20 14 | - 18 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /assets/dmg-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/assets/dmg-background.png -------------------------------------------------------------------------------- /assets/dmg-background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/assets/dmg-background@2x.png -------------------------------------------------------------------------------- /base.r: -------------------------------------------------------------------------------- 1 | data 'TMPL' (128, "LPic") { 2 | $"1344 6566 6175 6C74 204C 616E 6775 6167" /* .Default Languag */ 3 | $"6520 4944 4457 5244 0543 6F75 6E74 4F43" /* e IDDWRD.CountOC */ 4 | $"4E54 042A 2A2A 2A4C 5354 430B 7379 7320" /* NT.****LSTC.sys */ 5 | $"6C61 6E67 2049 4444 5752 441E 6C6F 6361" /* lang IDDWRD.loca */ 6 | $"6C20 7265 7320 4944 2028 6F66 6673 6574" /* l res ID (offset */ 7 | $"2066 726F 6D20 3530 3030 4457 5244 1032" /* from 5000DWRD.2 */ 8 | $"2D62 7974 6520 6C61 6E67 7561 6765 3F44" /* -byte language?D */ 9 | $"5752 4404 2A2A 2A2A 4C53 5445" /* WRD.****LSTE */ 10 | }; 11 | 12 | data 'LPic' (5000) { 13 | $"0000 0001 0000 0000 0000" 14 | }; 15 | 16 | data 'STR#' (5000, "English") { 17 | $"0006 0745 6E67 6C69 7368 0541 6772 6565" /* ...English.Agree */ 18 | $"0844 6973 6167 7265 6505 5072 696E 7407" /* .Disagree.Print. */ 19 | $"5361 7665 2E2E 2E7B 4966 2079 6F75 2061" /* Save...{If you a */ 20 | $"6772 6565 2077 6974 6820 7468 6520 7465" /* gree with the te */ 21 | $"726D 7320 6F66 2074 6869 7320 6C69 6365" /* rms of this lice */ 22 | $"6E73 652C 2070 7265 7373 2022 4167 7265" /* nse, press "Agre */ 23 | $"6522 2074 6F20 696E 7374 616C 6C20 7468" /* e" to install th */ 24 | $"6520 736F 6674 7761 7265 2E20 2049 6620" /* e software. If */ 25 | $"796F 7520 646F 206E 6F74 2061 6772 6565" /* you do not agree */ 26 | $"2C20 7072 6573 7320 2244 6973 6167 7265" /* , press "Disagre */ 27 | $"6522 2E" /* e". */ 28 | }; 29 | 30 | data 'styl' (5000, "English") { 31 | $"0001 0000 0000 000E 0011 0015 0000 000C" 32 | $"0000 0000 0000" 33 | }; 34 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import process from 'node:process'; 3 | import path from 'node:path'; 4 | import fs from 'node:fs'; 5 | import {fileURLToPath} from 'node:url'; 6 | import meow from 'meow'; 7 | import appdmg from 'appdmg'; 8 | import plist from 'plist'; 9 | import Ora from 'ora'; 10 | import {execa} from 'execa'; 11 | import addLicenseAgreementIfNeeded from './sla.js'; 12 | import composeIcon from './compose-icon.js'; 13 | 14 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 15 | 16 | if (process.platform !== 'darwin') { 17 | console.error('macOS only'); 18 | process.exit(1); 19 | } 20 | 21 | const cli = meow(` 22 | Usage 23 | $ create-dmg [destination] 24 | 25 | Options 26 | --overwrite Overwrite existing DMG with the same name 27 | --identity= Manually set code signing identity (automatic by default) 28 | --dmg-title= Manually set DMG title (must be <=27 characters) [default: App name] 29 | 30 | Examples 31 | $ create-dmg 'Lungo.app' 32 | $ create-dmg 'Lungo.app' Build/Releases 33 | `, { 34 | importMeta: import.meta, 35 | flags: { 36 | overwrite: { 37 | type: 'boolean', 38 | }, 39 | identity: { 40 | type: 'string', 41 | }, 42 | dmgTitle: { 43 | type: 'string', 44 | }, 45 | }, 46 | }); 47 | 48 | let [appPath, destinationPath] = cli.input; 49 | 50 | if (!appPath) { 51 | console.error('Specify an app'); 52 | process.exit(1); 53 | } 54 | 55 | if (!destinationPath) { 56 | destinationPath = process.cwd(); 57 | } 58 | 59 | const infoPlistPath = path.join(appPath, 'Contents/Info.plist'); 60 | 61 | let infoPlist; 62 | try { 63 | infoPlist = fs.readFileSync(infoPlistPath, 'utf8'); 64 | } catch (error) { 65 | if (error.code === 'ENOENT') { 66 | console.error(`Could not find \`${path.relative(process.cwd(), appPath)}\``); 67 | process.exit(1); 68 | } 69 | 70 | throw error; 71 | } 72 | 73 | const ora = new Ora('Creating DMG'); 74 | ora.start(); 75 | 76 | async function init() { 77 | let appInfo; 78 | try { 79 | appInfo = plist.parse(infoPlist); 80 | } catch { 81 | const {stdout} = await execa('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', infoPlistPath]); 82 | appInfo = plist.parse(stdout); 83 | } 84 | 85 | const appName = appInfo.CFBundleDisplayName ?? appInfo.CFBundleName; 86 | if (!appName) { 87 | throw new Error('The app must have `CFBundleDisplayName` or `CFBundleName` defined in its `Info.plist`.'); 88 | } 89 | 90 | const dmgTitle = cli.flags.dmgTitle ?? appName; 91 | const dmgFilename = `${appName} ${appInfo.CFBundleShortVersionString}.dmg`; 92 | const dmgPath = path.join(destinationPath, dmgFilename); 93 | 94 | if (dmgTitle.length > 27) { 95 | ora.fail('The disk image title cannot exceed 27 characters. This is a limitation in a dependency: https://github.com/LinusU/node-alias/issues/7'); 96 | process.exit(1); 97 | } 98 | 99 | if (cli.flags.overwrite) { 100 | try { 101 | fs.unlinkSync(dmgPath); 102 | } catch {} 103 | } 104 | 105 | const hasAppIcon = appInfo.CFBundleIconFile; 106 | let composedIconPath; 107 | if (hasAppIcon) { 108 | ora.text = 'Creating icon'; 109 | const appIconName = appInfo.CFBundleIconFile.replace(/\.icns/, ''); 110 | composedIconPath = await composeIcon(path.join(appPath, 'Contents/Resources', `${appIconName}.icns`)); 111 | } 112 | 113 | const dmgFormat = 'ULFO'; // ULFO requires macOS 10.11+ 114 | const dmgFilesystem = 'APFS'; // APFS requires macOS 10.13+ 115 | 116 | const ee = appdmg({ 117 | target: dmgPath, 118 | basepath: process.cwd(), 119 | specification: { 120 | title: dmgTitle, 121 | icon: composedIconPath, 122 | // 123 | // Use transparent background and `background-color` option when this is fixed: 124 | // https://github.com/LinusU/node-appdmg/issues/135 125 | background: path.join(__dirname, 'assets/dmg-background.png'), 126 | 'icon-size': 160, 127 | format: dmgFormat, 128 | filesystem: dmgFilesystem, 129 | window: { 130 | size: { 131 | width: 660, 132 | height: 400, 133 | }, 134 | }, 135 | contents: [ 136 | { 137 | x: 180, 138 | y: 170, 139 | type: 'file', 140 | path: appPath, 141 | }, 142 | { 143 | x: 480, 144 | y: 170, 145 | type: 'link', 146 | path: '/Applications', 147 | }, 148 | ], 149 | }, 150 | }); 151 | 152 | ee.on('progress', info => { 153 | if (info.type === 'step-begin') { 154 | ora.text = info.title; 155 | } 156 | }); 157 | 158 | ee.on('finish', async () => { 159 | try { 160 | ora.text = 'Adding Software License Agreement if needed'; 161 | await addLicenseAgreementIfNeeded(dmgPath, dmgFormat); 162 | 163 | ora.text = 'Code signing DMG'; 164 | let identity; 165 | const {stdout} = await execa('/usr/bin/security', ['find-identity', '-v', '-p', 'codesigning']); 166 | if (cli.flags.identity && stdout.includes(`"${cli.flags.identity}"`)) { 167 | identity = cli.flags.identity; 168 | } else if (!cli.flags.identity && stdout.includes('Developer ID Application:')) { 169 | identity = 'Developer ID Application'; 170 | } else if (!cli.flags.identity && stdout.includes('Mac Developer:')) { 171 | identity = 'Mac Developer'; 172 | } else if (!cli.flags.identity && stdout.includes('Apple Development:')) { 173 | identity = 'Apple Development'; 174 | } 175 | 176 | if (!identity) { 177 | const error = new Error(); // eslint-disable-line unicorn/error-message 178 | error.stderr = 'No suitable code signing identity found'; 179 | throw error; 180 | } 181 | 182 | try { 183 | await execa('/usr/bin/codesign', ['--sign', identity, dmgPath]); 184 | } catch (error) { 185 | ora.fail(`Code signing failed. The DMG is fine, just not code signed.\n${error.stderr?.trim() ?? error}`); 186 | process.exit(2); 187 | } 188 | 189 | const {stderr} = await execa('/usr/bin/codesign', [dmgPath, '--display', '--verbose=2']); 190 | 191 | const match = /^Authority=(.*)$/m.exec(stderr); 192 | if (!match) { 193 | ora.fail('Not code signed'); 194 | process.exit(1); 195 | } 196 | 197 | ora.info(`Code signing identity: ${match[1]}`).start(); 198 | ora.succeed(`Created “${dmgFilename}”`); 199 | } catch (error) { 200 | ora.fail(`${error.stderr?.trim() ?? error}`); 201 | process.exit(2); 202 | } 203 | }); 204 | 205 | ee.on('error', error => { 206 | ora.fail(`Building the DMG failed. ${error}`); 207 | process.exit(1); 208 | }); 209 | } 210 | 211 | try { 212 | await init(); 213 | } catch (error) { 214 | ora.fail(error?.stack || error); 215 | process.exit(1); 216 | } 217 | -------------------------------------------------------------------------------- /compose-icon.js: -------------------------------------------------------------------------------- 1 | import {Buffer} from 'node:buffer'; 2 | import fs from 'node:fs'; 3 | import {promisify} from 'node:util'; 4 | import path from 'node:path'; 5 | import {fileURLToPath} from 'node:url'; 6 | import {execa} from 'execa'; 7 | import {temporaryFile} from 'tempy'; 8 | import baseGm from 'gm'; 9 | import icns from 'icns-lib'; 10 | 11 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 12 | 13 | const gm = baseGm.subClass({imageMagick: true}); 14 | const readFile = promisify(fs.readFile); 15 | const writeFile = promisify(fs.writeFile); 16 | 17 | const filterMap = (map, filterFunction) => Object.fromEntries(Object.entries(map).filter(element => filterFunction(element)).map(([key, item]) => [key, item])); 18 | 19 | // Drive icon from `/System/Library/Extensions/IOStorageFamily.kext/Contents/Resources/Removable.icns`` 20 | const baseDiskIconPath = `${__dirname}/disk-icon.icns`; 21 | 22 | const biggestPossibleIconType = 'ic10'; 23 | 24 | async function baseComposeIcon(type, appIcon, mountIcon, composedIcon) { 25 | mountIcon = gm(mountIcon); 26 | appIcon = gm(appIcon); 27 | 28 | const [appIconSize, mountIconSize] = await Promise.all([ 29 | promisify(appIcon.size.bind(appIcon))(), 30 | promisify(appIcon.size.bind(mountIcon))(), 31 | ]); 32 | 33 | // Change the perspective of the app icon to match the mount drive icon 34 | 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}`); 35 | 36 | // Resize the app icon to fit it inside the mount icon, aspect ration should not be kept to create the perspective illution 37 | appIcon = appIcon.resize(mountIconSize.width / 1.58, mountIconSize.height / 1.82, '!'); 38 | 39 | const temporaryAppIconPath = temporaryFile({extension: 'png'}); 40 | await promisify(appIcon.write.bind(appIcon))(temporaryAppIconPath); 41 | 42 | // Compose the two icons 43 | const iconGravityFactor = mountIconSize.height * 0.063; 44 | mountIcon = mountIcon.composite(temporaryAppIconPath).gravity('Center').geometry(`+0-${iconGravityFactor}`); 45 | 46 | composedIcon[type] = await promisify(mountIcon.toBuffer.bind(mountIcon))(); 47 | } 48 | 49 | const hasGm = async () => { 50 | try { 51 | await execa('gm', ['-version']); 52 | return true; 53 | } catch (error) { 54 | if (error.code === 'ENOENT') { 55 | return false; 56 | } 57 | 58 | throw error; 59 | } 60 | }; 61 | 62 | export default async function composeIcon(appIconPath) { 63 | if (!await hasGm()) { 64 | return baseDiskIconPath; 65 | } 66 | 67 | const baseDiskIcons = filterMap(icns.parse(await readFile(baseDiskIconPath)), ([key]) => icns.isImageType(key)); 68 | const appIcon = filterMap(icns.parse(await readFile(appIconPath)), ([key]) => icns.isImageType(key)); 69 | 70 | const composedIcon = {}; 71 | await Promise.all(Object.entries(appIcon).map(async ([type, icon]) => { 72 | if (baseDiskIcons[type]) { 73 | return baseComposeIcon(type, icon, baseDiskIcons[type], composedIcon); 74 | } 75 | 76 | console.warn('There is no base image for this type', type); 77 | })); 78 | 79 | if (!composedIcon[biggestPossibleIconType]) { 80 | // Make sure the highest-resolution variant is generated 81 | const largestAppIcon = Object.values(appIcon).sort((a, b) => Buffer.byteLength(b) - Buffer.byteLength(a))[0]; 82 | await baseComposeIcon(biggestPossibleIconType, largestAppIcon, baseDiskIcons[biggestPossibleIconType], composedIcon); 83 | } 84 | 85 | const temporaryComposedIcon = temporaryFile({extension: 'icns'}); 86 | 87 | await writeFile(temporaryComposedIcon, icns.format(composedIcon)); 88 | 89 | return temporaryComposedIcon; 90 | } 91 | -------------------------------------------------------------------------------- /disk-icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/disk-icon.icns -------------------------------------------------------------------------------- /fixtures/Fixture-no-icon.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 15G1217 7 | CFBundleDevelopmentRegion 8 | English 9 | CFBundleExecutable 10 | fixture 11 | CFBundleIdentifier 12 | com.sindresorhus.create-dmg.fixture 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | Fixture 17 | LSApplicationCategoryType 18 | 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 0.0.1 23 | CFBundleSignature 24 | ???? 25 | CFBundleSupportedPlatforms 26 | 27 | MacOSX 28 | 29 | CFBundleVersion 30 | 0.0.1 31 | DTCompiler 32 | com.apple.compilers.llvm.clang.1_0 33 | DTPlatformBuild 34 | 8C38 35 | DTPlatformVersion 36 | GM 37 | DTSDKBuild 38 | 16C58 39 | DTSDKName 40 | macosx10.12 41 | DTXcode 42 | 0820 43 | DTXcodeBuild 44 | 8C38 45 | LSMinimumSystemVersion 46 | 10.11 47 | NSMainNibFile 48 | MainMenu 49 | NSPrincipalClass 50 | EventViewerApplication 51 | NSSupportsSuddenTermination 52 | YES 53 | 54 | 55 | -------------------------------------------------------------------------------- /fixtures/Fixture-no-icon.app/Contents/MacOS/fixture: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture-no-icon.app/Contents/MacOS/fixture -------------------------------------------------------------------------------- /fixtures/Fixture-no-icon.app/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /fixtures/Fixture-no-icon.app/Contents/Resources/English.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture-no-icon.app/Contents/Resources/English.lproj/InfoPlist.strings -------------------------------------------------------------------------------- /fixtures/Fixture-no-icon.app/Contents/Resources/English.lproj/MainMenu.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture-no-icon.app/Contents/Resources/English.lproj/MainMenu.nib -------------------------------------------------------------------------------- /fixtures/Fixture-with-binary-plist.app/Contents/Info.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture-with-binary-plist.app/Contents/Info.plist -------------------------------------------------------------------------------- /fixtures/Fixture-with-binary-plist.app/Contents/MacOS/fixture: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture-with-binary-plist.app/Contents/MacOS/fixture -------------------------------------------------------------------------------- /fixtures/Fixture-with-binary-plist.app/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/InfoPlist.strings -------------------------------------------------------------------------------- /fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/MainMenu.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/MainMenu.nib -------------------------------------------------------------------------------- /fixtures/Fixture-with-binary-plist.app/Contents/Resources/app.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture-with-binary-plist.app/Contents/Resources/app.icns -------------------------------------------------------------------------------- /fixtures/Fixture.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 15G1217 7 | CFBundleDevelopmentRegion 8 | English 9 | CFBundleExecutable 10 | fixture 11 | CFBundleIconFile 12 | app.icns 13 | CFBundleIdentifier 14 | com.sindresorhus.create-dmg.fixture 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | Fixture 19 | LSApplicationCategoryType 20 | 21 | CFBundlePackageType 22 | APPL 23 | CFBundleShortVersionString 24 | 0.0.1 25 | CFBundleSignature 26 | ???? 27 | CFBundleSupportedPlatforms 28 | 29 | MacOSX 30 | 31 | CFBundleVersion 32 | 0.0.1 33 | DTCompiler 34 | com.apple.compilers.llvm.clang.1_0 35 | DTPlatformBuild 36 | 8C38 37 | DTPlatformVersion 38 | GM 39 | DTSDKBuild 40 | 16C58 41 | DTSDKName 42 | macosx10.12 43 | DTXcode 44 | 0820 45 | DTXcodeBuild 46 | 8C38 47 | LSMinimumSystemVersion 48 | 10.11 49 | NSMainNibFile 50 | MainMenu 51 | NSPrincipalClass 52 | EventViewerApplication 53 | NSSupportsSuddenTermination 54 | YES 55 | 56 | 57 | -------------------------------------------------------------------------------- /fixtures/Fixture.app/Contents/MacOS/fixture: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture.app/Contents/MacOS/fixture -------------------------------------------------------------------------------- /fixtures/Fixture.app/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /fixtures/Fixture.app/Contents/Resources/English.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture.app/Contents/Resources/English.lproj/InfoPlist.strings -------------------------------------------------------------------------------- /fixtures/Fixture.app/Contents/Resources/English.lproj/MainMenu.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture.app/Contents/Resources/English.lproj/MainMenu.nib -------------------------------------------------------------------------------- /fixtures/Fixture.app/Contents/Resources/app.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/fixtures/Fixture.app/Contents/Resources/app.icns -------------------------------------------------------------------------------- /icon-example-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/icon-example-app.png -------------------------------------------------------------------------------- /icon-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/icon-example.png -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-dmg", 3 | "version": "7.0.0", 4 | "description": "Create a good-looking DMG for your macOS app in seconds", 5 | "license": "MIT", 6 | "repository": "sindresorhus/create-dmg", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "bin": { 15 | "create-dmg": "./cli.js" 16 | }, 17 | "engines": { 18 | "node": ">=18" 19 | }, 20 | "scripts": { 21 | "test": "ava" 22 | }, 23 | "files": [ 24 | "cli.js", 25 | "compose-icon.js", 26 | "assets", 27 | "disk-icon.icns", 28 | "sla.js", 29 | "base.r" 30 | ], 31 | "keywords": [ 32 | "cli-app", 33 | "cli", 34 | "create", 35 | "dmg", 36 | "disk", 37 | "image", 38 | "macos", 39 | "mac", 40 | "app", 41 | "application", 42 | "apple" 43 | ], 44 | "dependencies": { 45 | "appdmg": "^0.6.6", 46 | "execa": "^8.0.1", 47 | "gm": "^1.25.0", 48 | "icns-lib": "^1.0.1", 49 | "meow": "^13.1.0", 50 | "ora": "^8.0.1", 51 | "plist": "^3.1.0", 52 | "tempy": "^3.1.0" 53 | }, 54 | "devDependencies": { 55 | "ava": "^6.1.1", 56 | "xo": "^0.56.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # create-dmg 2 | 3 | > Create a good-looking [DMG](https://en.wikipedia.org/wiki/Apple_Disk_Image) for your macOS app in seconds 4 | 5 | Imagine you have finished a macOS app, exported it from Xcode, and now want to distribute it to users. The most common way of distributing an app outside the Mac App Store is by putting it in a `.dmg` file. These are hard to create, especially good-looking ones. You can either pay for a GUI app where you have to customize an existing design or you can run some homebrewed Bash script and you still have to design it. This tool does everything for you, so you can play with your 🐈 instead. 6 | 7 | 8 | 9 | *This tool is intentionally opinionated and simple. I'm not interested in adding lots of options.* 10 | 11 | ## Install 12 | 13 | Ensure you have [Node.js](https://nodejs.org) 18 or later installed. Then run the following: 14 | 15 | ```sh 16 | npm install --global create-dmg 17 | ``` 18 | 19 | ## Usage 20 | 21 | ``` 22 | $ create-dmg --help 23 | 24 | Usage 25 | $ create-dmg [destination] 26 | 27 | Options 28 | --overwrite Overwrite existing DMG with the same name 29 | --identity= Manually set code signing identity (automatic by default) 30 | --dmg-title= Manually set DMG title (must be <=27 characters) [default: App name] 31 | 32 | Examples 33 | $ create-dmg 'Lungo.app' 34 | $ create-dmg 'Lungo.app' Build/Releases 35 | ``` 36 | 37 | ## DMG 38 | 39 | The DMG requires macOS 10.13 or later and has the filename `App Name 0.0.0.dmg`. For example, `Lungo 1.0.0.dmg`. 40 | 41 | It will try to code sign the DMG, but the DMG is still created and fine even if the code signing fails, for example if you don't have a developer certificate. 42 | 43 | **Important:** Don't forget to [notarize your DMG](https://stackoverflow.com/a/60800864/64949). 44 | 45 | 46 | 47 | ### Software license agreement 48 | 49 | If either `license.txt`, `license.rtf`, or `sla.r` ([raw SLAResources file](https://download.developer.apple.com/Developer_Tools/software_licensing_for_udif/slas_for_udifs_1.0.dmg)) are present in the same directory as the app, it will be added as a software agreement when opening the image. The image will not be mounted unless the user indicates agreement with the license. 50 | 51 | `/usr/bin/rez` (from [Command Line Tools for Xcode](https://developer.apple.com/download/more/)) must be installed. 52 | 53 | ### DMG icon 54 | 55 | [GraphicsMagick](http://www.graphicsmagick.org) is required to create the custom DMG icon that's based on the app icon and the macOS mounted device icon. 56 | 57 | #### Steps using [Homebrew](https://brew.sh) 58 | 59 | ```sh 60 | brew install graphicsmagick imagemagick 61 | ``` 62 | 63 | #### Icon example 64 | 65 | Original icon → DMG icon 66 | 67 | 68 | 69 | ## Related 70 | 71 | - [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults 72 | - [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add “Launch at Login” functionality to your macOS 73 | - [My apps](https://sindresorhus.com/apps) 74 | - [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift+archived%3Afalse&type=repositories) 75 | -------------------------------------------------------------------------------- /screenshot-cli.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/screenshot-cli.gif -------------------------------------------------------------------------------- /screenshot-dmg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/screenshot-dmg.png -------------------------------------------------------------------------------- /sla.js: -------------------------------------------------------------------------------- 1 | import {Buffer} from 'node:buffer'; 2 | import process from 'node:process'; 3 | import fs from 'node:fs'; 4 | import path from 'node:path'; 5 | import {fileURLToPath} from 'node:url'; 6 | import {execa} from 'execa'; 7 | import {temporaryFile} from 'tempy'; 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 | 11 | function getRtfUnicodeEscapedString(text) { 12 | let result = ''; 13 | for (let index = 0; index < text.length; index++) { 14 | if (text[index] === '\\' || text[index] === '{' || text[index] === '}' || text[index] === '\n') { 15 | result += `\\${text[index]}`; 16 | } else if (text[index] === '\r') { 17 | // ignore 18 | } else if (text.codePointAt(index) <= 0x7F) { 19 | result += text[index]; 20 | } else { 21 | result += `\\u${text.codePointAt(index)}?`; 22 | } 23 | } 24 | 25 | return result; 26 | } 27 | 28 | function wrapInRtf(text) { 29 | return '\t$"7B5C 7274 6631 5C61 6E73 695C 616E 7369"\n' 30 | + '\t$"6370 6731 3235 325C 636F 636F 6172 7466"\n' 31 | + '\t$"3135 3034 5C63 6F63 6F61 7375 6272 7466"\n' 32 | + '\t$"3833 300A 7B5C 666F 6E74 7462 6C5C 6630"\n' 33 | + '\t$"5C66 7377 6973 735C 6663 6861 7273 6574"\n' 34 | + '\t$"3020 4865 6C76 6574 6963 613B 7D0A 7B5C"\n' 35 | + '\t$"636F 6C6F 7274 626C 3B5C 7265 6432 3535"\n' 36 | + '\t$"5C67 7265 656E 3235 355C 626C 7565 3235"\n' 37 | + '\t$"353B 7D0A 7B5C 2A5C 6578 7061 6E64 6564"\n' 38 | + '\t$"636F 6C6F 7274 626C 3B3B 7D0A 5C70 6172"\n' 39 | + '\t$"645C 7478 3536 305C 7478 3131 3230 5C74"\n' 40 | + '\t$"7831 3638 305C 7478 3232 3430 5C74 7832"\n' 41 | + '\t$"3830 305C 7478 3333 3630 5C74 7833 3932"\n' 42 | + '\t$"305C 7478 3434 3830 5C74 7835 3034 305C"\n' 43 | + '\t$"7478 3536 3030 5C74 7836 3136 305C 7478"\n' 44 | + '\t$"616C 5C70 6172 7469 6768 7465 6E66 6163"\n' 45 | + '\t$"746F 7230 0A0A 5C66 305C 6673 3234 205C"\n' 46 | + `${serializeString('63663020' + Buffer.from(getRtfUnicodeEscapedString(text)).toString('hex').toUpperCase() + '7D')}`; 47 | } 48 | 49 | function serializeString(text) { 50 | return '\t$"' + text.match(/.{1,32}/g).map(x => x.match(/.{1,4}/g).join(' ')).join('"\n\t$"') + '"'; 51 | } 52 | 53 | export default async function sla(dmgPath, dmgFormat) { 54 | // Valid SLA filenames 55 | const rawSlaFile = path.join(process.cwd(), 'sla.r'); 56 | const rtfSlaFile = path.join(process.cwd(), 'license.rtf'); 57 | const txtSlaFile = path.join(process.cwd(), 'license.txt'); 58 | 59 | const hasRaw = fs.existsSync(rawSlaFile); 60 | const hasRtf = fs.existsSync(rtfSlaFile); 61 | const hasTxt = fs.existsSync(txtSlaFile); 62 | 63 | if (!hasRaw && !hasRtf && !hasTxt) { 64 | return; 65 | } 66 | 67 | const temporaryDmgPath = temporaryFile({extension: 'dmg'}); 68 | 69 | // UDCO or UDRO format is required to be able to unflatten 70 | // Convert and unflatten DMG (original format will be restored at the end) 71 | await execa('/usr/bin/hdiutil', ['convert', '-format', 'UDCO', dmgPath, '-o', temporaryDmgPath]); 72 | await execa('/usr/bin/hdiutil', ['unflatten', temporaryDmgPath]); 73 | 74 | if (hasRaw) { 75 | // If user-defined sla.r file exists, add it to dmg with 'rez' utility 76 | await execa('/usr/bin/rez', ['-a', rawSlaFile, '-o', temporaryDmgPath]); 77 | } else { 78 | // Generate sla.r file from text/rtf file 79 | // Use base.r file as a starting point 80 | let data = fs.readFileSync(path.join(__dirname, 'base.r'), 'utf8'); 81 | let plainText = ''; 82 | 83 | // Generate RTF version and preserve plain text 84 | data += '\ndata \'RTF \' (5000, "English") {\n'; 85 | 86 | if (hasRtf) { 87 | data += serializeString((fs.readFileSync(rtfSlaFile).toString('hex').toUpperCase())); 88 | ({stdout: plainText} = await execa('/usr/bin/textutil', ['-convert', 'txt', '-stdout', rtfSlaFile])); 89 | } else { 90 | plainText = fs.readFileSync(txtSlaFile, 'utf8'); 91 | data += wrapInRtf(plainText); 92 | } 93 | 94 | data += '\n};\n'; 95 | 96 | // Generate plain text version 97 | // Used as an alternate for command-line deployments 98 | data += '\ndata \'TEXT\' (5000, "English") {\n'; 99 | data += serializeString(Buffer.from(plainText, 'utf8').toString('hex').toUpperCase()); 100 | data += '\n};\n'; 101 | 102 | // Save sla.r file, add it to DMG with `rez` utility 103 | const temporarySlaFile = temporaryFile({extension: 'r'}); 104 | fs.writeFileSync(temporarySlaFile, data, 'utf8'); 105 | await execa('/usr/bin/rez', ['-a', temporarySlaFile, '-o', temporaryDmgPath]); 106 | } 107 | 108 | // Flatten and convert back to original dmgFormat 109 | await execa('/usr/bin/hdiutil', ['flatten', temporaryDmgPath]); 110 | await execa('/usr/bin/hdiutil', ['convert', '-format', dmgFormat, temporaryDmgPath, '-o', dmgPath, '-ov']); 111 | } 112 | -------------------------------------------------------------------------------- /stuff/dmg-background.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/create-dmg/c6f5453bcb0d24e73f570785c87d37f3cd6f9b17/stuff/dmg-background.sketch -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs'; 3 | import {fileURLToPath} from 'node:url'; 4 | import test from 'ava'; 5 | import {execa} from 'execa'; 6 | import {temporaryDirectory} from 'tempy'; 7 | 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | 10 | test('main', async t => { 11 | const cwd = temporaryDirectory(); 12 | 13 | try { 14 | await execa(path.join(__dirname, 'cli.js'), ['--identity=0', path.join(__dirname, 'fixtures/Fixture.app')], {cwd}); 15 | } catch (error) { 16 | // Silence code signing failure 17 | if (!error.message.includes('No suitable code signing')) { 18 | throw error; 19 | } 20 | } 21 | 22 | t.true(fs.existsSync(path.join(cwd, 'Fixture 0.0.1.dmg'))); 23 | }); 24 | 25 | test('binary plist', async t => { 26 | const cwd = temporaryDirectory(); 27 | 28 | try { 29 | await execa(path.join(__dirname, 'cli.js'), ['--identity=0', path.join(__dirname, 'fixtures/Fixture-with-binary-plist.app')], {cwd}); 30 | } catch (error) { 31 | // Silence code signing failure 32 | if (!error.message.includes('No suitable code signing')) { 33 | throw error; 34 | } 35 | } 36 | 37 | t.true(fs.existsSync(path.join(cwd, 'Fixture 0.0.1.dmg'))); 38 | }); 39 | 40 | test('app without icon', async t => { 41 | const cwd = temporaryDirectory(); 42 | 43 | try { 44 | await execa(path.join(__dirname, 'cli.js'), ['--identity=0', path.join(__dirname, 'fixtures/Fixture-no-icon.app')], {cwd}); 45 | } catch (error) { 46 | // Silence code signing failure 47 | if (!error.message.includes('No suitable code signing')) { 48 | throw error; 49 | } 50 | } 51 | 52 | t.true(fs.existsSync(path.join(cwd, 'Fixture 0.0.1.dmg'))); 53 | }); 54 | --------------------------------------------------------------------------------