diff --git a/.github/funding.yml b/.github/funding.yml deleted file mode 100644 index 15edf6e..0000000 --- a/.github/funding.yml +++ /dev/null @@ -1,4 +0,0 @@ -github: sindresorhus -open_collective: sindresorhus -patreon: sindresorhus -custom: https://sindresorhus.com/donate diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..dc47f7c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,22 @@ +name: CI +on: + - push + - pull_request +jobs: + test: + name: Node.js ${{ matrix.node-version }} + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + node-version: + - 18 + - 16 + - 14 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6d9d707..0000000 --- a/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -os: osx -language: node_js -node_js: - - '12' - - '10' - - '8' diff --git a/base.r b/base.r new file mode 100644 index 0000000..e2b4574 --- /dev/null +++ b/base.r @@ -0,0 +1,33 @@ +data 'TMPL' (128, "LPic") { + $"1344 6566 6175 6C74 204C 616E 6775 6167" /* .Default Languag */ + $"6520 4944 4457 5244 0543 6F75 6E74 4F43" /* e IDDWRD.CountOC */ + $"4E54 042A 2A2A 2A4C 5354 430B 7379 7320" /* NT.****LSTC.sys */ + $"6C61 6E67 2049 4444 5752 441E 6C6F 6361" /* lang IDDWRD.loca */ + $"6C20 7265 7320 4944 2028 6F66 6673 6574" /* l res ID (offset */ + $"2066 726F 6D20 3530 3030 4457 5244 1032" /* from 5000DWRD.2 */ + $"2D62 7974 6520 6C61 6E67 7561 6765 3F44" /* -byte language?D */ + $"5752 4404 2A2A 2A2A 4C53 5445" /* WRD.****LSTE */ +}; + +data 'LPic' (5000) { + $"0000 0001 0000 0000 0000" +}; + +data 'STR#' (5000, "English") { + $"0006 0745 6E67 6C69 7368 0541 6772 6565" /* ...English.Agree */ + $"0844 6973 6167 7265 6505 5072 696E 7407" /* .Disagree.Print. */ + $"5361 7665 2E2E 2E7B 4966 2079 6F75 2061" /* Save...{If you a */ + $"6772 6565 2077 6974 6820 7468 6520 7465" /* gree with the te */ + $"726D 7320 6F66 2074 6869 7320 6C69 6365" /* rms of this lice */ + $"6E73 652C 2070 7265 7373 2022 4167 7265" /* nse, press "Agre */ + $"6522 2074 6F20 696E 7374 616C 6C20 7468" /* e" to install th */ + $"6520 736F 6674 7761 7265 2E20 2049 6620" /* e software. If */ + $"796F 7520 646F 206E 6F74 2061 6772 6565" /* you do not agree */ + $"2C20 7072 6573 7320 2244 6973 6167 7265" /* , press "Disagre */ + $"6522 2E" /* e". */ +}; + +data 'styl' (5000, "English") { + $"0001 0000 0000 000E 0011 0015 0000 000C" + $"0000 0000 0000" +}; diff --git a/cli.js b/cli.js index 3828da7..e999cbe 100755 --- a/cli.js +++ b/cli.js @@ -1,13 +1,17 @@ #!/usr/bin/env node -'use strict'; -const path = require('path'); -const fs = require('fs'); -const meow = require('meow'); -const appdmg = require('appdmg'); -const plist = require('plist'); -const Ora = require('ora'); -const execa = require('execa'); -const composeIcon = require('./compose-icon'); +import process from 'node:process'; +import path from 'node:path'; +import fs from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import meow from 'meow'; +import appdmg from 'appdmg'; +import plist from 'plist'; +import Ora from 'ora'; +import {execa} from 'execa'; +import addLicenseAgreementIfNeeded from './sla.js'; +import composeIcon from './compose-icon.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); if (process.platform !== 'darwin') { console.error('macOS only'); @@ -19,42 +23,44 @@ const cli = meow(` $ create-dmg [destination] Options - --overwrite Overwrite existing DMG with the same name - --identity= Manually set code signing identity (automatic by default) - --format Set DMG format (default ULFO) + --overwrite Overwrite existing DMG with the same name + --identity= Manually set code signing identity (automatic by default) + --dmg-title= Manually set DMG title (must be <=27 characters) [default: App name] Examples $ create-dmg 'Lungo.app' $ create-dmg 'Lungo.app' Build/Releases `, { + importMeta: import.meta, flags: { overwrite: { - type: 'boolean' + type: 'boolean', }, identity: { - type: 'string' + type: 'string', }, - format: { + dmgTitle: { type: 'string', - default: 'ULFO' - } - } + }, + }, }); -let [appPath, destPath] = cli.input; +let [appPath, destinationPath] = cli.input; if (!appPath) { console.error('Specify an app'); process.exit(1); } -if (!destPath) { - destPath = process.cwd(); +if (!destinationPath) { + destinationPath = process.cwd(); } +const infoPlistPath = path.join(appPath, 'Contents/Info.plist'); + let infoPlist; try { - infoPlist = fs.readFileSync(path.join(appPath, 'Contents/Info.plist'), 'utf8'); + infoPlist = fs.readFileSync(infoPlistPath, 'utf8'); } catch (error) { if (error.code === 'ENOENT') { console.error(`Could not find \`${path.relative(process.cwd(), appPath)}\``); @@ -64,57 +70,84 @@ try { throw error; } -const appInfo = plist.parse(infoPlist); -const appName = appInfo.CFBundleDisplayName || appInfo.CFBundleName; -const appIconName = appInfo.CFBundleIconFile.replace(/\.icns/, ''); -const dmgPath = path.join(destPath, `${appName} ${appInfo.CFBundleShortVersionString}.dmg`); - const ora = new Ora('Creating DMG'); ora.start(); async function init() { + let appInfo; + try { + appInfo = plist.parse(infoPlist); + } catch { + const {stdout} = await execa('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', infoPlistPath]); + appInfo = plist.parse(stdout); + } + + const appName = appInfo.CFBundleDisplayName || appInfo.CFBundleName; + if (!appName) { + throw new Error('The app must have `CFBundleDisplayName` or `CFBundleName` defined in its `Info.plist`.'); + } + + const dmgTitle = cli.flags.dmgTitle || appName; + const dmgFilename = `${appName} ${appInfo.CFBundleShortVersionString}.dmg`; + const dmgPath = path.join(destinationPath, dmgFilename); + + if (dmgTitle.length > 27) { + 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'); + process.exit(1); + } + if (cli.flags.overwrite) { try { fs.unlinkSync(dmgPath); - } catch (_) {} + } catch {} } - ora.text = 'Creating icon'; - const composedIconPath = await composeIcon(path.join(appPath, 'Contents/Resources', `${appIconName}.icns`)); + const hasAppIcon = appInfo.CFBundleIconFile; + let composedIconPath; + if (hasAppIcon) { + ora.text = 'Creating icon'; + const appIconName = appInfo.CFBundleIconFile.replace(/\.icns/, ''); + composedIconPath = await composeIcon(path.join(appPath, 'Contents/Resources', `${appIconName}.icns`)); + } + + // Xcode 14+ only supports building apps for macOS 10.13+ + const dmgFormat = 'ULFO'; // ULFO requires macOS 10.11+ + const dmgFilesystem = 'HFS+'; const ee = appdmg({ target: dmgPath, basepath: process.cwd(), specification: { - title: appName, + title: dmgTitle, 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: cli.flags.format, + format: dmgFormat, + filesystem: dmgFilesystem, window: { size: { width: 660, - height: 400 - } + height: 400, + }, }, contents: [ { x: 180, y: 170, type: 'file', - path: appPath + path: appPath, }, { x: 480, y: 170, type: 'link', - path: '/Applications' - } - ] - } + path: '/Applications', + }, + ], + }, }); ee.on('progress', info => { @@ -125,29 +158,42 @@ async function init() { 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 = 'Adding Software License Agreement if needed'; + await addLicenseAgreementIfNeeded(dmgPath, dmgFormat); + + if (hasAppIcon) { + 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']); + const {stdout} = await execa('/usr/bin/security', ['find-identity', '-v', '-p', 'codesigning']); if (cli.flags.identity && stdout.includes(`"${cli.flags.identity}"`)) { identity = cli.flags.identity; } else if (!cli.flags.identity && stdout.includes('Developer ID Application:')) { identity = 'Developer ID Application'; } else if (!cli.flags.identity && stdout.includes('Mac Developer:')) { identity = 'Mac Developer'; + } else if (!cli.flags.identity && stdout.includes('Apple Development:')) { + identity = 'Apple Development'; } if (!identity) { - const error = new Error(); + const error = new Error(); // eslint-disable-line unicorn/error-message error.stderr = 'No suitable code signing identity found'; throw error; } - await execa('codesign', ['--sign', identity, dmgPath]); - const {stderr} = await execa('codesign', [dmgPath, '--display', '--verbose=2']); + try { + await execa('/usr/bin/codesign', ['--sign', identity, dmgPath]); + } catch (error) { + ora.fail(`Code signing failed. The DMG is fine, just not code signed.\n${error.stderr?.trim() ?? error}`); + process.exit(2); + } + + const {stderr} = await execa('/usr/bin/codesign', [dmgPath, '--display', '--verbose=2']); const match = /^Authority=(.*)$/m.exec(stderr); if (!match) { @@ -156,9 +202,9 @@ async function init() { } ora.info(`Code signing identity: ${match[1]}`).start(); - ora.succeed('DMG created'); + ora.succeed(`Created “${dmgFilename}”`); } catch (error) { - ora.fail(`Code signing failed. The DMG is fine, just not code signed.\n${error.stderr.trim()}`); + ora.fail(`${error.stderr?.trim() ?? error}`); process.exit(2); } }); @@ -169,7 +215,9 @@ async function init() { }); } -init().catch(error => { - ora.fail(error); +try { + await init(); +} catch (error) { + ora.fail((error && error.stack) || error); process.exit(1); -}); +} diff --git a/compose-icon.js b/compose-icon.js index d34fe1c..b9b2c05 100644 --- a/compose-icon.js +++ b/compose-icon.js @@ -1,36 +1,47 @@ -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'); - +import {Buffer} from 'node:buffer'; +import fs from 'node:fs'; +import {promisify} from 'node:util'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {execa} from 'execa'; +import {temporaryFile} from 'tempy'; +import baseGm from 'gm'; +import icns from 'icns-lib'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const gm = baseGm.subClass({imageMagick: true}); 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}), {}); +const filterMap = (map, filterFunction) => Object.fromEntries(Object.entries(map).filter(element => filterFunction(element)).map(([key, item]) => [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) { +const biggestPossibleIconType = 'ic10'; + +async function baseComposeIcon(type, appIcon, mountIcon, composedIcon) { mountIcon = gm(mountIcon); appIcon = gm(appIcon); - const appIconSize = await promisify(appIcon.size.bind(appIcon))(); - const mountIconSize = appIconSize; + + const [appIconSize, mountIconSize] = await Promise.all([ + promisify(appIcon.size.bind(appIcon))(), + promisify(appIcon.size.bind(mountIcon))(), + ]); // 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, '!'); + appIcon = appIcon.resize(mountIconSize.width / 1.58, mountIconSize.height / 1.82, '!'); - const tempAppIconPath = tempy.file({extension: 'png'}); - await promisify(appIcon.write.bind(appIcon))(tempAppIconPath); + const temporaryAppIconPath = temporaryFile({extension: 'png'}); + await promisify(appIcon.write.bind(appIcon))(temporaryAppIconPath); // Compose the two icons - const iconGravityFactor = mountIconSize.height * 0.155; - mountIcon = mountIcon.composite(tempAppIconPath).gravity('Center').geometry(`+0-${iconGravityFactor}`); + const iconGravityFactor = mountIconSize.height * 0.063; + mountIcon = mountIcon.composite(temporaryAppIconPath).gravity('Center').geometry(`+0-${iconGravityFactor}`); composedIcon[type] = await promisify(mountIcon.toBuffer.bind(mountIcon))(); } @@ -48,7 +59,7 @@ const hasGm = async () => { } }; -module.exports = async appIconPath => { +export default async function composeIcon(appIconPath) { if (!await hasGm()) { return baseDiskIconPath; } @@ -59,15 +70,21 @@ module.exports = async appIconPath => { const composedIcon = {}; await Promise.all(Object.entries(appIcon).map(async ([type, icon]) => { if (baseDiskIcons[type]) { - return composeIcon(type, icon, baseDiskIcons[type], composedIcon); + return baseComposeIcon(type, icon, baseDiskIcons[type], composedIcon); } console.warn('There is no base image for this type', type); })); - const tempComposedIcon = tempy.file({extension: 'icns'}); + if (!composedIcon[biggestPossibleIconType]) { + // Make sure the highest-resolution variant is generated + const largestAppIcon = Object.values(appIcon).sort((a, b) => Buffer.byteLength(b) - Buffer.byteLength(a))[0]; + await baseComposeIcon(biggestPossibleIconType, largestAppIcon, baseDiskIcons[biggestPossibleIconType], composedIcon); + } - await writeFile(tempComposedIcon, icns.format(composedIcon)); + const temporaryComposedIcon = temporaryFile({extension: 'icns'}); - return tempComposedIcon; -}; + await writeFile(temporaryComposedIcon, icns.format(composedIcon)); + + return temporaryComposedIcon; +} diff --git a/disk-icon.icns b/disk-icon.icns index cbf98ae..734d7bf 100644 Binary files a/disk-icon.icns and b/disk-icon.icns differ diff --git a/fixtures/Fixture-no-icon.app/Contents/Info.plist b/fixtures/Fixture-no-icon.app/Contents/Info.plist new file mode 100644 index 0000000..11c1169 --- /dev/null +++ b/fixtures/Fixture-no-icon.app/Contents/Info.plist @@ -0,0 +1,54 @@ + + + + + BuildMachineOSBuild + 15G1217 + CFBundleDevelopmentRegion + English + CFBundleExecutable + fixture + CFBundleIdentifier + com.sindresorhus.create-dmg.fixture + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Fixture + LSApplicationCategoryType + + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.0.1 + CFBundleSignature + ???? + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 0.0.1 + DTCompiler + com.apple.compilers.llvm.clang.1_0 + DTPlatformBuild + 8C38 + DTPlatformVersion + GM + DTSDKBuild + 16C58 + DTSDKName + macosx10.12 + DTXcode + 0820 + DTXcodeBuild + 8C38 + LSMinimumSystemVersion + 10.11 + NSMainNibFile + MainMenu + NSPrincipalClass + EventViewerApplication + NSSupportsSuddenTermination + YES + + diff --git a/fixture.app/Contents/MacOS/fixture b/fixtures/Fixture-no-icon.app/Contents/MacOS/fixture similarity index 100% rename from fixture.app/Contents/MacOS/fixture rename to fixtures/Fixture-no-icon.app/Contents/MacOS/fixture diff --git a/fixture.app/Contents/PkgInfo b/fixtures/Fixture-no-icon.app/Contents/PkgInfo similarity index 100% rename from fixture.app/Contents/PkgInfo rename to fixtures/Fixture-no-icon.app/Contents/PkgInfo diff --git a/fixture.app/Contents/Resources/English.lproj/InfoPlist.strings b/fixtures/Fixture-no-icon.app/Contents/Resources/English.lproj/InfoPlist.strings similarity index 100% rename from fixture.app/Contents/Resources/English.lproj/InfoPlist.strings rename to fixtures/Fixture-no-icon.app/Contents/Resources/English.lproj/InfoPlist.strings diff --git a/fixture.app/Contents/Resources/English.lproj/MainMenu.nib b/fixtures/Fixture-no-icon.app/Contents/Resources/English.lproj/MainMenu.nib similarity index 100% rename from fixture.app/Contents/Resources/English.lproj/MainMenu.nib rename to fixtures/Fixture-no-icon.app/Contents/Resources/English.lproj/MainMenu.nib diff --git a/fixtures/Fixture-with-binary-plist.app/Contents/Info.plist b/fixtures/Fixture-with-binary-plist.app/Contents/Info.plist new file mode 100644 index 0000000..940d968 Binary files /dev/null and b/fixtures/Fixture-with-binary-plist.app/Contents/Info.plist differ diff --git a/fixtures/Fixture-with-binary-plist.app/Contents/MacOS/fixture b/fixtures/Fixture-with-binary-plist.app/Contents/MacOS/fixture new file mode 100755 index 0000000..58aa377 Binary files /dev/null and b/fixtures/Fixture-with-binary-plist.app/Contents/MacOS/fixture differ diff --git a/fixtures/Fixture-with-binary-plist.app/Contents/PkgInfo b/fixtures/Fixture-with-binary-plist.app/Contents/PkgInfo new file mode 100644 index 0000000..bd04210 --- /dev/null +++ b/fixtures/Fixture-with-binary-plist.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPL???? \ No newline at end of file diff --git a/fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/InfoPlist.strings b/fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/InfoPlist.strings new file mode 100644 index 0000000..5e45963 Binary files /dev/null and b/fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/InfoPlist.strings differ diff --git a/fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/MainMenu.nib b/fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/MainMenu.nib new file mode 100644 index 0000000..fdd1e70 Binary files /dev/null and b/fixtures/Fixture-with-binary-plist.app/Contents/Resources/English.lproj/MainMenu.nib differ diff --git a/fixture.app/Contents/Resources/app.icns b/fixtures/Fixture-with-binary-plist.app/Contents/Resources/app.icns similarity index 100% rename from fixture.app/Contents/Resources/app.icns rename to fixtures/Fixture-with-binary-plist.app/Contents/Resources/app.icns diff --git a/fixture.app/Contents/Info.plist b/fixtures/Fixture.app/Contents/Info.plist similarity index 100% rename from fixture.app/Contents/Info.plist rename to fixtures/Fixture.app/Contents/Info.plist diff --git a/fixtures/Fixture.app/Contents/MacOS/fixture b/fixtures/Fixture.app/Contents/MacOS/fixture new file mode 100755 index 0000000..58aa377 Binary files /dev/null and b/fixtures/Fixture.app/Contents/MacOS/fixture differ diff --git a/fixtures/Fixture.app/Contents/PkgInfo b/fixtures/Fixture.app/Contents/PkgInfo new file mode 100644 index 0000000..bd04210 --- /dev/null +++ b/fixtures/Fixture.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPL???? \ No newline at end of file diff --git a/fixtures/Fixture.app/Contents/Resources/English.lproj/InfoPlist.strings b/fixtures/Fixture.app/Contents/Resources/English.lproj/InfoPlist.strings new file mode 100644 index 0000000..5e45963 Binary files /dev/null and b/fixtures/Fixture.app/Contents/Resources/English.lproj/InfoPlist.strings differ diff --git a/fixtures/Fixture.app/Contents/Resources/English.lproj/MainMenu.nib b/fixtures/Fixture.app/Contents/Resources/English.lproj/MainMenu.nib new file mode 100644 index 0000000..fdd1e70 Binary files /dev/null and b/fixtures/Fixture.app/Contents/Resources/English.lproj/MainMenu.nib differ diff --git a/fixtures/Fixture.app/Contents/Resources/app.icns b/fixtures/Fixture.app/Contents/Resources/app.icns new file mode 100644 index 0000000..2685aa9 Binary files /dev/null and b/fixtures/Fixture.app/Contents/Resources/app.icns differ diff --git a/icon-example.png b/icon-example.png index af1c697..2c54daa 100644 Binary files a/icon-example.png and b/icon-example.png differ diff --git a/license b/license index e7af2f7..fa7ceba 100644 --- a/license +++ b/license @@ -1,6 +1,6 @@ MIT License -Copyright (c) Sindre Sorhus (sindresorhus.com) +Copyright (c) Sindre Sorhus (https://sindresorhus.com) 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: diff --git a/package.json b/package.json index 0c63f01..1c43d41 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,33 @@ { "name": "create-dmg", - "version": "4.1.0", + "version": "6.0.1", "description": "Create a good-looking DMG for your macOS app in seconds", "license": "MIT", "repository": "sindresorhus/create-dmg", + "funding": "https://github.com/sponsors/sindresorhus", "author": { "name": "Sindre Sorhus", "email": "sindresorhus@gmail.com", - "url": "sindresorhus.com" + "url": "https://sindresorhus.com" }, + "type": "module", "bin": { - "create-dmg": "cli.js" + "create-dmg": "./cli.js" }, "engines": { - "node": ">=8" + "node": ">=14.16" }, "scripts": { - "test": "xo && ava" + "test": "ava" }, "files": [ "cli.js", "compose-icon.js", "assets", "disk-icon.icns", - "seticon" + "seticon", + "sla.js", + "base.r" ], "keywords": [ "cli-app", @@ -39,17 +43,17 @@ "apple" ], "dependencies": { - "appdmg": "^0.6.0", - "execa": "^1.0.0", - "gm": "^1.23.1", + "appdmg": "^0.6.6", + "execa": "^6.1.0", + "gm": "^1.25.0", "icns-lib": "^1.0.1", - "meow": "^5.0.0", - "ora": "^3.0.0", - "plist": "^3.0.1", - "tempy": "^0.3.0" + "meow": "^11.0.0", + "ora": "^6.1.2", + "plist": "^3.0.6", + "tempy": "^3.0.0" }, "devDependencies": { - "ava": "^1.4.1", - "xo": "^0.24.0" + "ava": "^5.2.0", + "xo": "^0.53.1" } } diff --git a/readme.md b/readme.md index de8c74b..74dfcb2 100644 --- a/readme.md +++ b/readme.md @@ -1,26 +1,20 @@ -# create-dmg [![Build Status](https://travis-ci.org/sindresorhus/create-dmg.svg?branch=master)](https://travis-ci.org/sindresorhus/create-dmg) +# create-dmg > Create a good-looking [DMG](https://en.wikipedia.org/wiki/Apple_Disk_Image) for your macOS app in seconds 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. - - -Discuss it on [Product Hunt](https://www.producthunt.com/posts/create-dmg) and [Twitter](https://twitter.com/sindresorhus/status/846416556754010112). + *This tool is intentionally opinionated and simple. I'm not interested in adding lots of options.* -You might also find my [`LaunchAtLogin`](https://github.com/sindresorhus/LaunchAtLogin) project useful. - - ## Install -Ensure you have [Node.js](https://nodejs.org) 8 or later installed. Then run the following: +Ensure you have [Node.js](https://nodejs.org) 14 or later installed. Then run the following: +```sh +npm install --global create-dmg ``` -$ npm install --global create-dmg -``` - ## Usage @@ -31,41 +25,50 @@ $ create-dmg --help $ create-dmg [destination] Options - --overwrite Overwrite existing DMG with the same name - --identity= Manually set code signing identity (automatic by default) - --format Set DMG format (default ULFO) + --overwrite Overwrite existing DMG with the same name + --identity= Manually set code signing identity (automatic by default) + --dmg-title= Manually set DMG title (must be <=27 characters) [default: App name] Examples $ create-dmg 'Lungo.app' $ create-dmg 'Lungo.app' Build/Releases ``` - ## DMG -The DMG requires macOS 10.11 or later with the default `format` and has the filename `App Name 0.0.0.dmg`, for example `Lungo 1.0.0.dmg`. +The DMG requires macOS 10.11 or later and has the filename `App Name 0.0.0.dmg`. For example, `Lungo 1.0.0.dmg`. 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. +**Important:** Don't forget to [notarize your DMG](https://stackoverflow.com/a/60800864/64949). + -### DMG Icon +### Software license agreement + +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. + +`/usr/bin/rez` (from [Command Line Tools for Xcode](https://developer.apple.com/download/more/)) must be installed. + +### DMG icon [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. -#### Steps using Homebrew +#### Steps using [Homebrew](https://brew.sh) -``` -$ brew install graphicsmagick imagemagick +```sh +brew install graphicsmagick imagemagick ``` -#### Icon Example +#### Icon example Original icon → DMG icon +## Related -## License - -MIT © [Sindre Sorhus](https://sindresorhus.com) +- [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults +- [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add “Launch at Login” functionality to your macOS +- [My apps](https://sindresorhus.com/apps) +- [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift+archived%3Afalse&type=repositories) diff --git a/sla.js b/sla.js new file mode 100644 index 0000000..65c5c7e --- /dev/null +++ b/sla.js @@ -0,0 +1,111 @@ +import {Buffer} from 'node:buffer'; +import process from 'node:process'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {execa} from 'execa'; +import {temporaryFile} from 'tempy'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function getRtfUnicodeEscapedString(text) { + let result = ''; + for (let index = 0; index < text.length; index++) { + if (text[index] === '\\' || text[index] === '{' || text[index] === '}' || text[index] === '\n') { + result += `\\${text[index]}`; + } else if (text[index] === '\r') { + // ignore + } else if (text.codePointAt(index) <= 0x7F) { + result += text[index]; + } else { + result += `\\u${text.codePointAt(index)}?`; + } + } + + return result; +} + +function wrapInRtf(text) { + return '\t$"7B5C 7274 6631 5C61 6E73 695C 616E 7369"\n' + + '\t$"6370 6731 3235 325C 636F 636F 6172 7466"\n' + + '\t$"3135 3034 5C63 6F63 6F61 7375 6272 7466"\n' + + '\t$"3833 300A 7B5C 666F 6E74 7462 6C5C 6630"\n' + + '\t$"5C66 7377 6973 735C 6663 6861 7273 6574"\n' + + '\t$"3020 4865 6C76 6574 6963 613B 7D0A 7B5C"\n' + + '\t$"636F 6C6F 7274 626C 3B5C 7265 6432 3535"\n' + + '\t$"5C67 7265 656E 3235 355C 626C 7565 3235"\n' + + '\t$"353B 7D0A 7B5C 2A5C 6578 7061 6E64 6564"\n' + + '\t$"636F 6C6F 7274 626C 3B3B 7D0A 5C70 6172"\n' + + '\t$"645C 7478 3536 305C 7478 3131 3230 5C74"\n' + + '\t$"7831 3638 305C 7478 3232 3430 5C74 7832"\n' + + '\t$"3830 305C 7478 3333 3630 5C74 7833 3932"\n' + + '\t$"305C 7478 3434 3830 5C74 7835 3034 305C"\n' + + '\t$"7478 3536 3030 5C74 7836 3136 305C 7478"\n' + + '\t$"616C 5C70 6172 7469 6768 7465 6E66 6163"\n' + + '\t$"746F 7230 0A0A 5C66 305C 6673 3234 205C"\n' + + `${serializeString('63663020' + Buffer.from(getRtfUnicodeEscapedString(text)).toString('hex').toUpperCase() + '7D')}`; +} + +function serializeString(text) { + return '\t$"' + text.match(/.{1,32}/g).map(x => x.match(/.{1,4}/g).join(' ')).join('"\n\t$"') + '"'; +} + +export default async function sla(dmgPath, dmgFormat) { + // Valid SLA filenames + const rawSlaFile = path.join(process.cwd(), 'sla.r'); + const rtfSlaFile = path.join(process.cwd(), 'license.rtf'); + const txtSlaFile = path.join(process.cwd(), 'license.txt'); + + const hasRaw = fs.existsSync(rawSlaFile); + const hasRtf = fs.existsSync(rtfSlaFile); + const hasTxt = fs.existsSync(txtSlaFile); + + if (!hasRaw && !hasRtf && !hasTxt) { + return; + } + + const temporaryDmgPath = temporaryFile({extension: 'dmg'}); + + // UDCO or UDRO format is required to be able to unflatten + // Convert and unflatten DMG (original format will be restored at the end) + await execa('/usr/bin/hdiutil', ['convert', '-format', 'UDCO', dmgPath, '-o', temporaryDmgPath]); + await execa('/usr/bin/hdiutil', ['unflatten', temporaryDmgPath]); + + if (hasRaw) { + // If user-defined sla.r file exists, add it to dmg with 'rez' utility + await execa('/usr/bin/rez', ['-a', rawSlaFile, '-o', temporaryDmgPath]); + } else { + // Generate sla.r file from text/rtf file + // Use base.r file as a starting point + let data = fs.readFileSync(path.join(__dirname, 'base.r'), 'utf8'); + let plainText = ''; + + // Generate RTF version and preserve plain text + data += '\ndata \'RTF \' (5000, "English") {\n'; + + if (hasRtf) { + data += serializeString((fs.readFileSync(rtfSlaFile).toString('hex').toUpperCase())); + ({stdout: plainText} = await execa('/usr/bin/textutil', ['-convert', 'txt', '-stdout', rtfSlaFile])); + } else { + plainText = fs.readFileSync(txtSlaFile, 'utf8'); + data += wrapInRtf(plainText); + } + + data += '\n};\n'; + + // Generate plain text version + // Used as an alternate for command-line deployments + data += '\ndata \'TEXT\' (5000, "English") {\n'; + data += serializeString(Buffer.from(plainText, 'utf8').toString('hex').toUpperCase()); + data += '\n};\n'; + + // Save sla.r file, add it to DMG with `rez` utility + const temporarySlaFile = temporaryFile({extension: 'r'}); + fs.writeFileSync(temporarySlaFile, data, 'utf8'); + await execa('/usr/bin/rez', ['-a', temporarySlaFile, '-o', temporaryDmgPath]); + } + + // Flatten and convert back to original dmgFormat + await execa('/usr/bin/hdiutil', ['flatten', temporaryDmgPath]); + await execa('/usr/bin/hdiutil', ['convert', '-format', dmgFormat, temporaryDmgPath, '-o', dmgPath, '-ov']); +} diff --git a/test.js b/test.js index cdb9afc..f09a6b7 100644 --- a/test.js +++ b/test.js @@ -1,17 +1,50 @@ -import path from 'path'; -import fs from 'fs'; +import path from 'node:path'; +import fs from 'node:fs'; +import {fileURLToPath} from 'node:url'; import test from 'ava'; -import execa from 'execa'; -import tempy from 'tempy'; +import {execa} from 'execa'; +import {temporaryDirectory} from 'tempy'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); test('main', async t => { - const cwd = tempy.directory(); + const cwd = temporaryDirectory(); + + try { + await execa(path.join(__dirname, 'cli.js'), [path.join(__dirname, 'fixtures/Fixture.app')], {cwd}); + } catch (error) { + // Silence code signing failure + if (!error.message.includes('Code signing failed')) { + throw error; + } + } + + t.true(fs.existsSync(path.join(cwd, 'Fixture 0.0.1.dmg'))); +}); + +test('binary plist', async t => { + const cwd = temporaryDirectory(); + + try { + await execa(path.join(__dirname, 'cli.js'), [path.join(__dirname, 'fixtures/Fixture-with-binary-plist.app')], {cwd}); + } catch (error) { + // Silence code signing failure + if (!error.message.includes('Code signing failed')) { + throw error; + } + } + + t.true(fs.existsSync(path.join(cwd, 'Fixture 0.0.1.dmg'))); +}); + +test('app without icon', async t => { + const cwd = temporaryDirectory(); try { - await execa(path.join(__dirname, 'cli.js'), [path.join(__dirname, 'fixture.app')], {cwd}); + await execa(path.join(__dirname, 'cli.js'), [path.join(__dirname, 'fixtures/Fixture-no-icon.app')], {cwd}); } catch (error) { // Silence code signing failure - if (!/Code signing failed/.test(error.message)) { + if (!error.message.includes('Code signing failed')) { throw error; } }