From 7d684a271a2d1ce62312ede284bf08faf45905d5 Mon Sep 17 00:00:00 2001 From: dxdc Date: Wed, 19 Feb 2020 11:20:58 +0000 Subject: [PATCH] Software licensing, support for pre-10.11 (#47) Co-authored-by: Sindre Sorhus --- base.r | 33 ++++++++++++++++ cli.js | 23 +++++++---- package.json | 4 +- readme.md | 10 ++++- sla.js | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 base.r create mode 100644 sla.js 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 6d5f834..f7429db 100755 --- a/cli.js +++ b/cli.js @@ -7,6 +7,7 @@ const appdmg = require('appdmg'); const plist = require('plist'); const Ora = require('ora'); const execa = require('execa'); +const addLicenseAgreementIfNeeded = require('./sla.js'); const composeIcon = require('./compose-icon'); if (process.platform !== 'darwin') { @@ -73,13 +74,13 @@ async function init() { try { appInfo = plist.parse(infoPlist); } catch (_) { - const {stdout} = await execa('plutil', ['-convert', 'xml1', '-o', '-', infoPlistPath]); + const {stdout} = await execa('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', infoPlistPath]); appInfo = plist.parse(stdout); } const appName = appInfo.CFBundleDisplayName || appInfo.CFBundleName; const appIconName = appInfo.CFBundleIconFile.replace(/\.icns/, ''); - const dmgTitle = appName.length > 27 ? (cli.flags['dmg-title'] || appName) : appName; + const dmgTitle = appName.length > 27 ? (cli.flags.dmgTitle || appName) : appName; const dmgPath = path.join(destinationPath, `${appName} ${appInfo.CFBundleShortVersionString}.dmg`); if (cli.flags.overwrite) { @@ -91,6 +92,11 @@ async function init() { ora.text = 'Creating icon'; const composedIconPath = await composeIcon(path.join(appPath, 'Contents/Resources', `${appIconName}.icns`)); + const minSystemVersion = (Object.prototype.hasOwnProperty.call(appInfo, 'LSMinimumSystemVersion') && appInfo.LSMinimumSystemVersion.length > 0) ? appInfo.LSMinimumSystemVersion.toString() : '10.11'; + const minorVersion = Number(minSystemVersion.split('.')[1]) || 0; + const dmgFormat = (minorVersion >= 11) ? 'ULFO' : 'UDZO'; // ULFO requires 10.11+ + ora.info(`Minimum runtime ${minSystemVersion} detected, using ${dmgFormat} format`).start(); + const ee = appdmg({ target: dmgPath, basepath: process.cwd(), @@ -102,7 +108,7 @@ async function init() { // https://github.com/LinusU/node-appdmg/issues/135 background: path.join(__dirname, 'assets/dmg-background.png'), 'icon-size': 160, - format: 'ULFO', + format: dmgFormat, window: { size: { width: 660, @@ -134,13 +140,16 @@ async function init() { ee.on('finish', async () => { try { + ora.text = 'Adding Software License Agreement if needed'; + await addLicenseAgreementIfNeeded(dmgPath, dmgFormat); + 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:')) { @@ -155,8 +164,8 @@ async function init() { throw error; } - await execa('codesign', ['--sign', identity, dmgPath]); - const {stderr} = await execa('codesign', [dmgPath, '--display', '--verbose=2']); + await execa('/usr/bin/codesign', ['--sign', identity, dmgPath]); + const {stderr} = await execa('/usr/bin/codesign', [dmgPath, '--display', '--verbose=2']); const match = /^Authority=(.*)$/m.exec(stderr); if (!match) { @@ -167,7 +176,7 @@ async function init() { 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()}`); + ora.fail(`Code signing failed. The DMG is fine, just not code signed.\n${Object.prototype.hasOwnProperty.call(error, 'stderr') ? error.stderr.trim() : error}`); process.exit(2); } }); diff --git a/package.json b/package.json index eb13eb7..8afdd3d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "compose-icon.js", "assets", "disk-icon.icns", - "seticon" + "seticon", + "sla.js", + "base.r" ], "keywords": [ "cli-app", diff --git a/readme.md b/readme.md index 241f04f..c683896 100644 --- a/readme.md +++ b/readme.md @@ -43,17 +43,23 @@ $ create-dmg --help ## 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`. +The DMG detects the minimum runtime of the app, and uses ULFO (macOS 10.11 or later) or UDZO as appropriate. The resulting image 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. +### Software license + +If `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 folder as the app, they 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` [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 diff --git a/sla.js b/sla.js new file mode 100644 index 0000000..35540e3 --- /dev/null +++ b/sla.js @@ -0,0 +1,106 @@ +const fs = require('fs'); +const path = require('path'); +const execa = require('execa'); +const tempy = require('tempy'); + +function getRtfUnicodeEscapedString(text) { + let result = ''; + for (let i = 0; i < text.length; i++) { + if (text[i] === '\\' || text[i] === '{' || text[i] === '}' || text[i] === '\n') { + result += `\\${text[i]}`; + } else if (text[i] === '\r') { + // ignore + } else if (text.charCodeAt(i) <= 0x7F) { + result += text[i]; + } else { + result += `\\u${text.codePointAt(i)}?`; + } + } + + 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$"') + '"'; +} + +module.exports = async (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 tempDmgPath = tempy.file({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', tempDmgPath]); + await execa('/usr/bin/hdiutil', ['unflatten', tempDmgPath]); + + if (hasRaw) { + // If user-defined sla.r file exists, add it to dmg with 'rez' utility + await execa('/usr/bin/rez', ['-a', rawSlaFile, '-o', tempDmgPath]); + } 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 tempSlaFile = tempy.file({extension: 'r'}); + fs.writeFileSync(tempSlaFile, data, 'utf8'); + await execa('/usr/bin/rez', ['-a', tempSlaFile, '-o', tempDmgPath]); + } + + // Flatten and convert back to original dmgFormat + await execa('/usr/bin/hdiutil', ['flatten', tempDmgPath]); + await execa('/usr/bin/hdiutil', ['convert', '-format', dmgFormat, tempDmgPath, '-o', dmgPath, '-ov']); +};