Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm run build
- run: npm test
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
node_modules
yarn.lock
.build
/compose-icon
/.build
/xcuserdata
.DS_Store
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added __image_snapshots__/test-js-main-1-snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions build-swift.sh
Original file line number Diff line number Diff line change
@@ -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"
54 changes: 18 additions & 36 deletions compose-icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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));

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions imageComposition/Package.swift
Original file line number Diff line number Diff line change
@@ -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: []
)
]
)
153 changes: 153 additions & 0 deletions imageComposition/Sources/ComposeIcon/main.swift
Original file line number Diff line number Diff line change
@@ -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 <app-icon-path> <mount-icon-path> <output-path>\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)
}
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -53,6 +55,11 @@
},
"devDependencies": {
"ava": "^6.1.1",
"jest-image-snapshot": "^6.5.1",
"xo": "^0.56.0"
},
"ava": {
"serial": true,
"timeout": "30s"
}
}
10 changes: 0 additions & 10 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading