Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3a570a0
feat: support Icon Composer icons for macOS
iamEvanYT Sep 17, 2025
0ad8833
feat: use existing `icon` property
iamEvanYT Sep 18, 2025
48145b1
docs: update icon docs
iamEvanYT Sep 18, 2025
d83658a
fix types
iamEvanYT Sep 18, 2025
0045714
feat: add icon composer test
iamEvanYT Sep 26, 2025
74102f4
refactor: fix and verify tests
iamEvanYT Sep 26, 2025
9c93331
feat: run macIconTest in CI
iamEvanYT Sep 27, 2025
3c83560
feat: add macIconTest to test-mac & upgrade to macos-26 runners
iamEvanYT Sep 27, 2025
2f9afb2
Merge branch 'master' into support-icon-composer-macos
mmaietta Sep 29, 2025
fb6c1b5
Merge branch 'master' into support-icon-composer-macos
mmaietta Oct 1, 2025
a54b850
Merge branch 'master' into support-icon-composer-macos
mmaietta Oct 3, 2025
a4ce530
chore: remove macos version check
iamEvanYT Oct 11, 2025
b92eec3
feat: copy icns file too as fallback
iamEvanYT Oct 11, 2025
fdcd1cf
fix: linux builds failing when .icon files exists
iamEvanYT Oct 11, 2025
6b0b46c
feat: add temporary icns file handling for compatibility in MacPackager
iamEvanYT Oct 11, 2025
4f66e03
fix: improve process of cloning the icns file
iamEvanYT Oct 12, 2025
f788939
fix
iamEvanYT Oct 12, 2025
4c2ac7f
fix: universal builds not working
iamEvanYT Oct 12, 2025
381bb5a
Merge branch 'master' into support-icon-composer-macos
iamEvanYT Oct 12, 2025
0b18cc7
refactor: cache asset catalog generation and improve icon resolver
iamEvanYT Oct 14, 2025
aeb4241
fix: better checks for actool version
iamEvanYT Oct 14, 2025
ee9bfd5
fix: `.icon` files causing builds to fail
iamEvanYT Oct 14, 2025
3924283
Merge branch 'master' into support-icon-composer-macos
mmaietta Oct 16, 2025
0ef3fde
fix: tests
iamEvanYT Oct 20, 2025
1838cab
Merge branch 'master' into support-icon-composer-macos
mmaietta Nov 2, 2025
d8352d2
Merge branch 'master' into support-icon-composer-macos
mmaietta Nov 8, 2025
6edafc7
fix: e2e test
iamEvanYT Nov 8, 2025
bd097f4
fix: broken mac tests
iamEvanYT Nov 8, 2025
194f65c
Merge branch 'master' into support-icon-composer-macos
iamEvanYT Nov 8, 2025
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
5 changes: 5 additions & 0 deletions .changeset/tender-berries-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"app-builder-lib": minor
---

feat: support Icon Composer icons for macOS
33 changes: 32 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Test

on:
push:
branches: master
branches: [master]
pull_request:
workflow_dispatch: # Allows you to run this workflow manually from the Actions tab
inputs:
Expand Down Expand Up @@ -210,6 +210,7 @@ jobs:
TEST_FILES: ${{ matrix.testFiles }}
FORCE_COLOR: 1

# Some tests fails while on macOS 26, so we'll keep it this way for now
test-mac:
runs-on: macos-latest
timeout-minutes: 20
Expand Down Expand Up @@ -242,3 +243,33 @@ jobs:
env:
TEST_FILES: ${{ matrix.testFiles }}
FORCE_COLOR: 1

test-mac-26:
runs-on: macos-26
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
testFiles:
- macIconTest
steps:
- name: Checkout code repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Setup Tests
uses: ./.github/actions/pretest
with:
cache-path: ~/Library/Caches/electron
cache-key: v-23.3.10-macos-electron

- name: Install toolset via brew
run: |
brew install powershell/tap/powershell
brew install --cask wine-stable
brew install rpm

- name: Test
run: pnpm ci:test
env:
TEST_FILES: ${{ matrix.testFiles }}
FORCE_COLOR: 1
4 changes: 2 additions & 2 deletions packages/app-builder-lib/scheme.json
Original file line number Diff line number Diff line change
Expand Up @@ -2849,7 +2849,7 @@
},
"icon": {
"default": "build/icon.icns",
"description": "The path to application icon.",
"description": "The path to application icon.\nAccepts `.icns` (legacy) or `.icon` (Icon Composer asset).\nIf a `.icon` asset is provided, it will be preferred and compiled to an asset catalog.",
"type": [
"null",
"string"
Expand Down Expand Up @@ -3484,7 +3484,7 @@
},
"icon": {
"default": "build/icon.icns",
"description": "The path to application icon.",
"description": "The path to application icon.\nAccepts `.icns` (legacy) or `.icon` (Icon Composer asset).\nIf a `.icon` asset is provided, it will be preferred and compiled to an asset catalog.",
"type": [
"null",
"string"
Expand Down
61 changes: 54 additions & 7 deletions packages/app-builder-lib/src/macPackager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@ import { notarize } from "@electron/notarize"
import { NotarizeOptionsNotaryTool, NotaryToolKeychainCredentials } from "@electron/notarize/lib/types"
import { PerFileSignOptions, SignOptions } from "@electron/osx-sign/dist/cjs/types"
import { Identity } from "@electron/osx-sign/dist/cjs/util-identities"
import { Arch, AsyncTaskManager, copyFile, deepAssign, exec, getArchSuffix, InvalidConfigurationError, log, orIfFileNotExist, statOrNull, unlinkIfExists, use } from "builder-util"
import {
Arch,
AsyncTaskManager,
copyFile,
deepAssign,
exec,
exists,
getArchSuffix,
InvalidConfigurationError,
log,
orIfFileNotExist,
statOrNull,
unlinkIfExists,
use,
} from "builder-util"
import { MemoLazy, Nullish } from "builder-util-runtime"
import * as fs from "fs/promises"
import { mkdir, readdir } from "fs/promises"
Expand Down Expand Up @@ -162,6 +176,14 @@ export class MacPackager extends PlatformPackager<MacConfiguration> {
`packaging`
)
const appFile = `${this.appInfo.productFilename}.app`

// Make sure the Assets.car file is the same for both architectures
const sourceCatalogPath = path.join(x64AppOutDir, appFile, "Contents/Resources/Assets.car")
if (await exists(sourceCatalogPath)) {
const targetCatalogPath = path.join(arm64AppOutPath, appFile, "Contents/Resources/Assets.car")
await fs.copyFile(sourceCatalogPath, targetCatalogPath)
}

const { makeUniversalApp } = require("@electron/universal")
await makeUniversalApp({
x64AppPath: path.join(x64AppOutDir, appFile),
Expand Down Expand Up @@ -502,19 +524,44 @@ export class MacPackager extends PlatformPackager<MacConfiguration> {
// https://github.com/electron-userland/electron-builder/issues/1278
appPlist.CFBundleExecutable = appFilename.endsWith(" Helper") ? appFilename.substring(0, appFilename.length - " Helper".length) : appFilename

const icon = await this.getIconPath()
if (icon != null) {
const resourcesPath = path.join(contentsPath, "Resources")

// Support both legacy `.icns` and modern `.icon` (Icon Composer) inputs via `mac.icon`.
// Prefer `.icon` if provided; still accept `.icns`.
const configuredIcon = this.platformSpecificBuildOptions.icon
const isIconComposer = typeof configuredIcon === "string" && configuredIcon.toLowerCase().endsWith(".icon")

// Set the app name
appPlist.CFBundleName = appInfo.productName
appPlist.CFBundleDisplayName = appInfo.productName

// Bundle legacy `icns` format - this should also run when `.icon` is provided
const setIcnsFile = async (iconPath: string) => {
const oldIcon = appPlist.CFBundleIconFile
const resourcesPath = path.join(contentsPath, "Resources")
if (oldIcon != null) {
await unlinkIfExists(path.join(resourcesPath, oldIcon))
}
const iconFileName = "icon.icns"
appPlist.CFBundleIconFile = iconFileName
await copyFile(icon, path.join(resourcesPath, iconFileName))
await copyFile(iconPath, path.join(resourcesPath, iconFileName))
}

const icnsFilePath = await this.getIconPath()
if (icnsFilePath != null) {
await setIcnsFile(icnsFilePath)
}

// Bundle new `icon` format
if (isIconComposer && configuredIcon) {
const iconComposerPath = await this.getResource(configuredIcon)
if (iconComposerPath) {
const { assetCatalog } = await this.generateAssetCatalogData(iconComposerPath)

// Create and setup the asset catalog
appPlist.CFBundleIconName = "Icon"
await fs.writeFile(path.join(resourcesPath, "Assets.car"), assetCatalog)
}
}
appPlist.CFBundleName = appInfo.productName
appPlist.CFBundleDisplayName = appInfo.productName

const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion
if (minimumSystemVersion != null) {
Expand Down
2 changes: 2 additions & 0 deletions packages/app-builder-lib/src/options/macOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface MacConfiguration extends PlatformSpecificBuildOptions {

/**
* The path to application icon.
* Accepts `.icns` (legacy) or `.icon` (Icon Composer asset).
* If a `.icon` asset is provided, it will be preferred and compiled to an asset catalog.
* @default build/icon.icns
*/
readonly icon?: string | null
Expand Down
57 changes: 57 additions & 0 deletions packages/app-builder-lib/src/platformPackager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { readdir } from "fs/promises"
import { Lazy } from "lazy-val"
import { Minimatch } from "minimatch"
import * as path from "path"
import * as fs from "fs/promises"
import * as os from "os"
import { AppInfo } from "./appInfo"
import { checkFileInArchive } from "./asar/asarFileChecker"
import { AsarPackager } from "./asar/asarUtil"
Expand All @@ -46,6 +48,7 @@ import {
import { executeAppBuilderAsJson } from "./util/appBuilder"
import { computeFileSets, computeNodeModuleFileSets, copyAppFiles, ELECTRON_COMPILE_SHIM_FILENAME, transformFiles } from "./util/appFileCopier"
import { expandMacro as doExpandMacro } from "./util/macroExpander"
import { AssetCatalogResult, generateAssetCatalogForIcon } from "./util/macosIconComposer"

export type DoPackOptions<DC extends PlatformSpecificBuildOptions> = {
outDir: string
Expand Down Expand Up @@ -779,7 +782,53 @@ export abstract class PlatformPackager<DC extends PlatformSpecificBuildOptions>
return (forceCodeSigningPlatform == null ? this.config.forceCodeSigning : forceCodeSigningPlatform) || false
}

private assetCatalogResults = new Map<string, Promise<AssetCatalogResult>>()
protected generateAssetCatalogData(iconPath: string): Promise<AssetCatalogResult> {
// Cache results
const cachedPromise = this.assetCatalogResults.get(iconPath)
if (cachedPromise) {
return cachedPromise
}

const promise = generateAssetCatalogForIcon(iconPath)
this.assetCatalogResults.set(iconPath, promise)
return promise
}

private cachedIcnsFromIconFile = new Map<string, Promise<string>>()
private async generateIcnsFromIcon(iconPath: string): Promise<string> {
const cachedPromise = this.cachedIcnsFromIconFile.get(iconPath)
if (cachedPromise) {
return cachedPromise
}

const runner = async () => {
const { icnsFile } = await this.generateAssetCatalogData(iconPath)

// Generate icns file
const tempDir = await fs.mkdtemp(path.resolve(os.tmpdir(), "icon-compile-"))
const tempIcnsPath = path.resolve(tempDir, "Icon.icns")
await fs.writeFile(tempIcnsPath, icnsFile)

return tempIcnsPath
}
const promise = runner()
this.cachedIcnsFromIconFile.set(iconPath, promise)
return promise
}

protected async getOrConvertIcon(format: IconFormat): Promise<string | null> {
if (format === "icns") {
const configuredIcon = this.platformSpecificBuildOptions.icon
// If it is a .icon file, generate the icns file and return the path to the icns file
if (configuredIcon?.endsWith(".icon")) {
const iconPath = await this.getResource(configuredIcon)
if (iconPath) {
return this.generateIcnsFromIcon(iconPath)
}
}
}

const result = await this.resolveIcon(asArray(this.platformSpecificBuildOptions.icon || this.config.icon), [], format)
if (result.length === 0) {
const framework = this.info.framework
Expand Down Expand Up @@ -814,9 +863,17 @@ export abstract class PlatformPackager<DC extends PlatformSpecificBuildOptions>
path.resolve(this.projectDir, output, `.icon-${outputFormat}`),
]
for (const source of sources) {
if (source.endsWith(".icon")) {
// Ignore .icon files: they will cause the format conversion to fail
continue
}
args.push("--input", source)
}
for (const source of fallbackSources) {
if (source.endsWith(".icon")) {
// Ignore .icon files: they will cause the format conversion to fail
continue
}
args.push("--fallback-input", source)
}

Expand Down
4 changes: 3 additions & 1 deletion packages/app-builder-lib/src/targets/LinuxTargetHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ export class LinuxTargetHelper {
// need to put here and not as default because need to resolve image size
const result = await packager.resolveIcon(sources, fallbackSources, "set")
this.maxIconPath = result[result.length - 1].file
return result

// Ignore .icon files for linux (they are exclusive for macOS)
return result.filter(icon => !icon.file.endsWith(".icon"))
}

getDescription(options: LinuxTargetSpecificOptions) {
Expand Down
110 changes: 110 additions & 0 deletions packages/app-builder-lib/src/util/macosIconComposer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Adapted from https://github.com/electron/packager/pull/1806

import { spawn } from "builder-util"
import * as fs from "fs/promises"
import * as os from "node:os"
import * as path from "node:path"
import * as plist from "plist"
import * as semver from "semver"

export interface AssetCatalogResult {
assetCatalog: Buffer
icnsFile: Buffer
}

const INVALID_ACTOOL_VERSION_ERROR = new Error(
"Failed to check actool version. Is Xcode 26 or higher installed? See output of the `actool --version` CLI command for more details."
)

async function checkActoolVersion(tmpDir: string) {
const acToolOutputFileName = path.resolve(tmpDir, "actool.log")

let versionInfo: Record<string, Record<string, string>> | undefined = undefined

try {
const acToolOutputFile = await fs.open(acToolOutputFileName, "w")
await spawn("actool", ["--version"], { stdio: ["ignore", acToolOutputFile.fd, acToolOutputFile.fd] })
const acToolVersionOutput = await fs.readFile(acToolOutputFileName, "utf8")
versionInfo = plist.parse(acToolVersionOutput) as Record<string, Record<string, string>>
} catch {
throw INVALID_ACTOOL_VERSION_ERROR
}

if (!versionInfo || !versionInfo["com.apple.actool.version"] || !versionInfo["com.apple.actool.version"]["short-bundle-version"]) {
throw INVALID_ACTOOL_VERSION_ERROR
}

const acToolVersion = versionInfo["com.apple.actool.version"]["short-bundle-version"]
if (!semver.gte(semver.coerce(acToolVersion)!, "26.0.0")) {
throw new Error(`Unsupported actool version. Must be on actool 26.0.0 or higher but found ${acToolVersion}. Install Xcode 26 or higher to get a supported version of actool.`)
}
}

/**
* Generates an asset catalog and extra assets that are useful for packaging the app.
* @param inputPath The path to the `.icon` file
* @returns The asset catalog and extra assets
*/
export async function generateAssetCatalogForIcon(inputPath: string): Promise<AssetCatalogResult> {
const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), "icon-compile-"))
const cleanup = async () => {
await fs.rm(tmpDir, {
recursive: true,
force: true,
})
}

try {
await checkActoolVersion(tmpDir)
} catch (error) {
await cleanup()
throw error
}

const iconPath = path.resolve(tmpDir, "Icon.icon")
const outputPath = path.resolve(tmpDir, "out")

try {
await fs.cp(inputPath, iconPath, {
recursive: true,
})

await fs.mkdir(outputPath, {
recursive: true,
})

await spawn("actool", [
iconPath,
"--compile",
outputPath,
"--output-format",
"human-readable-text",
"--notices",
"--warnings",
"--output-partial-info-plist",
path.resolve(outputPath, "assetcatalog_generated_info.plist"),
"--app-icon",
"Icon",
"--include-all-app-icons",
"--accent-color",
"AccentColor",
"--enable-on-demand-resources",
"NO",
"--development-region",
"en",
"--target-device",
"mac",
"--minimum-deployment-target",
"26.0",
"--platform",
"macosx",
])

const assetCatalog = await fs.readFile(path.resolve(outputPath, "Assets.car"))
const icnsFile = await fs.readFile(path.resolve(outputPath, "Icon.icns"))

return { assetCatalog, icnsFile }
} finally {
await cleanup()
}
}
Loading
Loading