diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e5c4df3..95ee0d9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,4 +18,5 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install + - run: npm run build - run: npm test diff --git a/.gitignore b/.gitignore index 239ecff..062f624 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ node_modules yarn.lock +.build +/compose-icon +/.build +/xcuserdata +.DS_Store diff --git a/__image_snapshots__/test-js-binary-plist-1-snap.png b/__image_snapshots__/test-js-binary-plist-1-snap.png new file mode 100644 index 0000000..6df00c3 Binary files /dev/null and b/__image_snapshots__/test-js-binary-plist-1-snap.png differ diff --git a/__image_snapshots__/test-js-main-1-snap.png b/__image_snapshots__/test-js-main-1-snap.png new file mode 100644 index 0000000..6df00c3 Binary files /dev/null and b/__image_snapshots__/test-js-main-1-snap.png differ diff --git a/build-swift.sh b/build-swift.sh new file mode 100755 index 0000000..13e3327 --- /dev/null +++ b/build-swift.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the Swift package for composing icons +echo "Building Swift package..." +cd imageComposition +xcrun swift build -c release --arch arm64 --arch x86_64 --product compose-icon + +# Copy the built executable to the root directory for easy access +cp ".build/apple/Products/Release/compose-icon" ../compose-icon + +echo "Swift executable built successfully: compose-icon" \ No newline at end of file diff --git a/compose-icon.js b/compose-icon.js index b9b2c05..af03de7 100644 --- a/compose-icon.js +++ b/compose-icon.js @@ -5,12 +5,10 @@ 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); @@ -22,48 +20,32 @@ const baseDiskIconPath = `${__dirname}/disk-icon.icns`; const biggestPossibleIconType = 'ic10'; async function baseComposeIcon(type, appIcon, mountIcon, composedIcon) { - mountIcon = gm(mountIcon); - appIcon = gm(appIcon); - - 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(mountIconSize.width / 1.58, mountIconSize.height / 1.82, '!'); - + // Write app and mount icons to temporary PNG files const temporaryAppIconPath = temporaryFile({extension: 'png'}); - await promisify(appIcon.write.bind(appIcon))(temporaryAppIconPath); - - // Compose the two icons - const iconGravityFactor = mountIconSize.height * 0.063; - mountIcon = mountIcon.composite(temporaryAppIconPath).gravity('Center').geometry(`+0-${iconGravityFactor}`); + const temporaryMountIconPath = temporaryFile({extension: 'png'}); + const temporaryOutputPath = temporaryFile({extension: 'png'}); - composedIcon[type] = await promisify(mountIcon.toBuffer.bind(mountIcon))(); -} + await writeFile(temporaryAppIconPath, appIcon); + await writeFile(temporaryMountIconPath, mountIcon); -const hasGm = async () => { + // Use Swift executable for image processing + const swiftExecutablePath = path.join(__dirname, 'compose-icon'); + try { - await execa('gm', ['-version']); - return true; + await execa(swiftExecutablePath, [ + temporaryAppIconPath, + temporaryMountIconPath, + temporaryOutputPath + ]); + + // Read the composed image back + composedIcon[type] = await readFile(temporaryOutputPath); } catch (error) { - if (error.code === 'ENOENT') { - return false; - } - - throw error; + throw new Error(`Swift image processing failed: ${error.message}`); } -}; +} export default async function composeIcon(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)); diff --git a/imageComposition/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/imageComposition/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/imageComposition/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/imageComposition/Package.swift b/imageComposition/Package.swift new file mode 100644 index 0000000..ce70423 --- /dev/null +++ b/imageComposition/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.7 + +import PackageDescription + +let package = Package( + name: "ComposeIcon", + platforms: [ + .macOS(.v11) + ], + products: [ + .executable( + name: "compose-icon", + targets: ["ComposeIcon"] + ) + ], + targets: [ + .executableTarget( + name: "ComposeIcon", + dependencies: [] + ) + ] +) diff --git a/imageComposition/Sources/ComposeIcon/main.swift b/imageComposition/Sources/ComposeIcon/main.swift new file mode 100644 index 0000000..2cb7d83 --- /dev/null +++ b/imageComposition/Sources/ComposeIcon/main.swift @@ -0,0 +1,153 @@ +import CoreGraphics +import CoreImage +import Foundation +import CoreImage.CIFilterBuiltins +import ImageIO +import UniformTypeIdentifiers + +func cgContext(width: Int, height: Int) -> CGContext? { + CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue + ) +} + +func perspectiveTransform(image: CGImage, width: Int, height: Int) -> CGImage? { + let ciImage = CIImage(cgImage: image) + let filter = CIFilter.perspectiveTransform() + + let w = CGFloat(width) + let h = CGFloat(height) + + filter.inputImage = ciImage + filter.topLeft = CGPoint(x: w * 0.08, y: h) + filter.topRight = CGPoint(x: w * 0.92, y: h) + filter.bottomLeft = CGPoint(x: 0, y: 0) + filter.bottomRight = CGPoint(x: w, y: 0) + + guard let outputImage = filter.outputImage else { return nil } + + let inputExtent = ciImage.extent + let croppedImage = outputImage.cropped(to: inputExtent) + + let ciContext = CIContext() + return ciContext.createCGImage(croppedImage, from: inputExtent) +} + +func resizeImage(image: CGImage, width: Int, height: Int) -> CGImage? { + guard let ctx = cgContext(width: width, height: height) else { return nil } + + ctx.interpolationQuality = .high + ctx.clear(CGRect(x: 0, y: 0, width: width, height: height)) + ctx.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + + return ctx.makeImage() +} + +func compositeImages(baseImage: CGImage, overlayImage: CGImage, offsetY: CGFloat) -> CGImage? { + let width = baseImage.width + let height = baseImage.height + + guard let ctx = cgContext(width: width, height: height) else { return nil } + + // Draw base image + ctx.draw(baseImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + // Calculate centered position with offset (matching GraphicsMagick's gravity('Center').geometry('+0-offset')) + let overlayWidth = overlayImage.width + let overlayHeight = overlayImage.height + + // Center horizontally and vertically, then apply upward offset + // CoreGraphics uses bottom-left origin, positive offsetY moves overlay up + let x = CGFloat(width - overlayWidth) / 2.0 + let centerY = CGFloat(height - overlayHeight) / 2.0 + let y = centerY + offsetY + + + // Draw overlay image + ctx.draw(overlayImage, in: CGRect(x: x, y: y, width: CGFloat(overlayWidth), height: CGFloat(overlayHeight))) + + return ctx.makeImage() +} + +func loadImage(from path: String) -> CGImage? { + guard let imageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil) else { + return nil + } + + return CGImageSourceCreateImageAtIndex(imageSource, 0, nil) +} + +func saveImage(_ image: CGImage, to path: String) -> Bool { + guard let destination = CGImageDestinationCreateWithURL(URL(fileURLWithPath: path) as CFURL, UTType.png.identifier as CFString, 1, nil) else { + return false + } + + CGImageDestinationAddImage(destination, image, nil) + return CGImageDestinationFinalize(destination) +} + +// Main program +guard CommandLine.arguments.count == 4 else { + fputs("Usage: compose-icon \n", stderr) + exit(1) +} + +let appIconPath = CommandLine.arguments[1] +let mountIconPath = CommandLine.arguments[2] +let outputPath = CommandLine.arguments[3] + +guard let appImage = loadImage(from: appIconPath), + let mountImage = loadImage(from: mountIconPath) else { + fputs("Error: Could not load input images\n", stderr) + exit(1) +} + +let appIconSize = (width: appImage.width, height: appImage.height) +let mountIconSize = (width: mountImage.width, height: mountImage.height) + +// Apply perspective transformation +guard let transformedAppImage = perspectiveTransform( + image: appImage, + width: appIconSize.width, + height: appIconSize.height +) else { + fputs("Error: Could not apply perspective transformation\n", stderr) + exit(1) +} + +// Resize app icon to fit inside mount icon (from JS: width / 1.58, height / 1.82) +let resizedWidth = Int((Double(mountIconSize.width) / 1.58).rounded()) +let resizedHeight = Int((Double(mountIconSize.height) / 1.82).rounded()) + +guard let resizedAppImage = resizeImage( + image: transformedAppImage, + width: resizedWidth, + height: resizedHeight +) else { + fputs("Error: Could not resize app image\n", stderr) + exit(1) +} + +// Composite images with offset (from JS: mountIconSize.height * 0.063) +let offsetY = CGFloat(mountIconSize.height) * 0.063 + +guard let composedImage = compositeImages( + baseImage: mountImage, + overlayImage: resizedAppImage, + offsetY: offsetY +) else { + fputs("Error: Could not composite images\n", stderr) + exit(1) +} + +// Save result +guard saveImage(composedImage, to: outputPath) else { + fputs("Error: Could not save output image\n", stderr) + exit(1) +} diff --git a/package.json b/package.json index 7208e35..d76e8df 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,14 @@ "node": ">=18" }, "scripts": { - "test": "ava" + "test": "ava", + "build": "./build-swift.sh", + "prepublishOnly": "npm run build" }, "files": [ "cli.js", "compose-icon.js", + "compose-icon", "assets", "disk-icon.icns", "sla.js", @@ -44,7 +47,6 @@ "dependencies": { "appdmg": "^0.6.6", "execa": "^8.0.1", - "gm": "^1.25.0", "icns-lib": "^1.0.1", "meow": "^13.1.0", "ora": "^8.0.1", @@ -53,6 +55,11 @@ }, "devDependencies": { "ava": "^6.1.1", + "jest-image-snapshot": "^6.5.1", "xo": "^0.56.0" + }, + "ava": { + "serial": true, + "timeout": "30s" } } diff --git a/readme.md b/readme.md index afb635b..d5889de 100644 --- a/readme.md +++ b/readme.md @@ -50,16 +50,6 @@ If either `license.txt`, `license.rtf`, or `sla.r` ([raw SLAResources file](http `/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](https://brew.sh) - -```sh -brew install graphicsmagick imagemagick -``` - #### Icon example Original icon → DMG icon diff --git a/test.js b/test.js index 3ae3ecc..9a29bed 100644 --- a/test.js +++ b/test.js @@ -4,9 +4,66 @@ import {fileURLToPath} from 'node:url'; import test from 'ava'; import {execa} from 'execa'; import {temporaryDirectory} from 'tempy'; +import jestImageSnapshot from 'jest-image-snapshot'; +import { spawnSync } from 'node:child_process'; +const { configureToMatchImageSnapshot } = jestImageSnapshot; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/** Validate that the icon for the DMG matches the snapshot (with a small tolerance to avoid flakiness) */ +const assertVolumeIconMatchesSnapshot = (t, dmgPath) => { + // Mount the DMG and extract the volume icon + const existingVolumes = new Set(fs.readdirSync('/Volumes')); + const mountResult = spawnSync('hdiutil', ['mount', dmgPath], { timeout: 10000 }); + + if (mountResult.status !== 0) { + throw new Error(`Failed to mount DMG: ${mountResult.stderr?.toString()}`); + } + + const volumes = new Set(fs.readdirSync('/Volumes')); + const mountLocation = [...volumes].find(x => !existingVolumes.has(x)); + t.truthy(mountLocation); + const dmgIconPath = path.join('/Volumes', mountLocation, '.VolumeIcon.icns') + const dirPath = path.dirname(dmgPath); + const iconPath = path.join(dirPath, 'VolumeIcon.icns'); + fs.copyFileSync(dmgIconPath, iconPath); + const unmountResult = spawnSync('hdiutil', ['unmount', '-force', path.join('/Volumes', mountLocation)], { timeout: 10000 }); + + if (unmountResult.status !== 0) { + throw new Error(`Failed to unmount ${mountLocation}: ${unmountResult.stderr?.toString()}`); + } + + const pngPath = path.join(dirPath, 'VolumeIcon.png'); + spawnSync('sips', ['-s', 'format', 'png', iconPath, '--out', pngPath], { timeout: 10000 }); + + // Compare the extracted icon to the snapshot + const image = fs.readFileSync(pngPath); + + // Create a Jest-like context for jest-image-snapshot + const jestContext = { + testPath: fileURLToPath(import.meta.url), + currentTestName: t.title, + _counters: new Map(), + snapshotState: { + _counters: new Map(), + _updateSnapshot: process.env.UPDATE_SNAPSHOT === 'true' ? 'all' : 'new', + updated: 0, + added: 0 + } + }; + + const result = configureToMatchImageSnapshot({ + failureThreshold: 0.01, + failureThresholdType: 'percent', + }).call(jestContext, image) + + if (result.pass) { + t.pass(); + } else { + t.fail(result.message()); + } +} + test('main', async t => { const cwd = temporaryDirectory(); @@ -18,8 +75,10 @@ test('main', async t => { throw error; } } + const dmgPath = path.join(cwd, 'Fixture 0.0.1.dmg'); + t.true(fs.existsSync(dmgPath)); - t.true(fs.existsSync(path.join(cwd, 'Fixture 0.0.1.dmg'))); + assertVolumeIconMatchesSnapshot(t, dmgPath); }); test('binary plist', async t => { @@ -34,7 +93,9 @@ test('binary plist', async t => { } } - t.true(fs.existsSync(path.join(cwd, 'Fixture 0.0.1.dmg'))); + const dmgPath = path.join(cwd, 'Fixture 0.0.1.dmg'); + t.true(fs.existsSync(dmgPath)); + assertVolumeIconMatchesSnapshot(t, dmgPath); }); test('app without icon', async t => {