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 => {