Skip to content

Commit b6a34c0

Browse files
authored
feat: support Icon Composer icons for macOS (electron-userland#9279)
1 parent 6a49f85 commit b6a34c0

13 files changed

Lines changed: 360 additions & 14 deletions

File tree

.changeset/tender-berries-fix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"app-builder-lib": minor
3+
---
4+
5+
feat: support Icon Composer icons for macOS

.github/workflows/test.yaml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Test
22

33
on:
44
push:
5-
branches: master
5+
branches: [master]
66
pull_request:
77
workflow_dispatch: # Allows you to run this workflow manually from the Actions tab
88
inputs:
@@ -210,6 +210,7 @@ jobs:
210210
TEST_FILES: ${{ matrix.testFiles }}
211211
FORCE_COLOR: 1
212212

213+
# Some tests fails while on macOS 26, so we'll keep it this way for now
213214
test-mac:
214215
runs-on: macos-latest
215216
timeout-minutes: 20
@@ -242,3 +243,33 @@ jobs:
242243
env:
243244
TEST_FILES: ${{ matrix.testFiles }}
244245
FORCE_COLOR: 1
246+
247+
test-mac-26:
248+
runs-on: macos-26
249+
timeout-minutes: 20
250+
strategy:
251+
fail-fast: false
252+
matrix:
253+
testFiles:
254+
- macIconTest
255+
steps:
256+
- name: Checkout code repository
257+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
258+
259+
- name: Setup Tests
260+
uses: ./.github/actions/pretest
261+
with:
262+
cache-path: ~/Library/Caches/electron
263+
cache-key: v-23.3.10-macos-electron
264+
265+
- name: Install toolset via brew
266+
run: |
267+
brew install powershell/tap/powershell
268+
brew install --cask wine-stable
269+
brew install rpm
270+
271+
- name: Test
272+
run: pnpm ci:test
273+
env:
274+
TEST_FILES: ${{ matrix.testFiles }}
275+
FORCE_COLOR: 1

packages/app-builder-lib/scheme.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2849,7 +2849,7 @@
28492849
},
28502850
"icon": {
28512851
"default": "build/icon.icns",
2852-
"description": "The path to application icon.",
2852+
"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.",
28532853
"type": [
28542854
"null",
28552855
"string"
@@ -3484,7 +3484,7 @@
34843484
},
34853485
"icon": {
34863486
"default": "build/icon.icns",
3487-
"description": "The path to application icon.",
3487+
"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.",
34883488
"type": [
34893489
"null",
34903490
"string"

packages/app-builder-lib/src/macPackager.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,21 @@ import { notarize } from "@electron/notarize"
22
import { NotarizeOptionsNotaryTool, NotaryToolKeychainCredentials } from "@electron/notarize/lib/types"
33
import { PerFileSignOptions, SignOptions } from "@electron/osx-sign/dist/cjs/types"
44
import { Identity } from "@electron/osx-sign/dist/cjs/util-identities"
5-
import { Arch, AsyncTaskManager, copyFile, deepAssign, exec, getArchSuffix, InvalidConfigurationError, log, orIfFileNotExist, statOrNull, unlinkIfExists, use } from "builder-util"
5+
import {
6+
Arch,
7+
AsyncTaskManager,
8+
copyFile,
9+
deepAssign,
10+
exec,
11+
exists,
12+
getArchSuffix,
13+
InvalidConfigurationError,
14+
log,
15+
orIfFileNotExist,
16+
statOrNull,
17+
unlinkIfExists,
18+
use,
19+
} from "builder-util"
620
import { MemoLazy, Nullish } from "builder-util-runtime"
721
import * as fs from "fs/promises"
822
import { mkdir, readdir } from "fs/promises"
@@ -162,6 +176,14 @@ export class MacPackager extends PlatformPackager<MacConfiguration> {
162176
`packaging`
163177
)
164178
const appFile = `${this.appInfo.productFilename}.app`
179+
180+
// Make sure the Assets.car file is the same for both architectures
181+
const sourceCatalogPath = path.join(x64AppOutDir, appFile, "Contents/Resources/Assets.car")
182+
if (await exists(sourceCatalogPath)) {
183+
const targetCatalogPath = path.join(arm64AppOutPath, appFile, "Contents/Resources/Assets.car")
184+
await fs.copyFile(sourceCatalogPath, targetCatalogPath)
185+
}
186+
165187
const { makeUniversalApp } = require("@electron/universal")
166188
await makeUniversalApp({
167189
x64AppPath: path.join(x64AppOutDir, appFile),
@@ -502,19 +524,44 @@ export class MacPackager extends PlatformPackager<MacConfiguration> {
502524
// https://github.com/electron-userland/electron-builder/issues/1278
503525
appPlist.CFBundleExecutable = appFilename.endsWith(" Helper") ? appFilename.substring(0, appFilename.length - " Helper".length) : appFilename
504526

505-
const icon = await this.getIconPath()
506-
if (icon != null) {
527+
const resourcesPath = path.join(contentsPath, "Resources")
528+
529+
// Support both legacy `.icns` and modern `.icon` (Icon Composer) inputs via `mac.icon`.
530+
// Prefer `.icon` if provided; still accept `.icns`.
531+
const configuredIcon = this.platformSpecificBuildOptions.icon
532+
const isIconComposer = typeof configuredIcon === "string" && configuredIcon.toLowerCase().endsWith(".icon")
533+
534+
// Set the app name
535+
appPlist.CFBundleName = appInfo.productName
536+
appPlist.CFBundleDisplayName = appInfo.productName
537+
538+
// Bundle legacy `icns` format - this should also run when `.icon` is provided
539+
const setIcnsFile = async (iconPath: string) => {
507540
const oldIcon = appPlist.CFBundleIconFile
508-
const resourcesPath = path.join(contentsPath, "Resources")
509541
if (oldIcon != null) {
510542
await unlinkIfExists(path.join(resourcesPath, oldIcon))
511543
}
512544
const iconFileName = "icon.icns"
513545
appPlist.CFBundleIconFile = iconFileName
514-
await copyFile(icon, path.join(resourcesPath, iconFileName))
546+
await copyFile(iconPath, path.join(resourcesPath, iconFileName))
547+
}
548+
549+
const icnsFilePath = await this.getIconPath()
550+
if (icnsFilePath != null) {
551+
await setIcnsFile(icnsFilePath)
552+
}
553+
554+
// Bundle new `icon` format
555+
if (isIconComposer && configuredIcon) {
556+
const iconComposerPath = await this.getResource(configuredIcon)
557+
if (iconComposerPath) {
558+
const { assetCatalog } = await this.generateAssetCatalogData(iconComposerPath)
559+
560+
// Create and setup the asset catalog
561+
appPlist.CFBundleIconName = "Icon"
562+
await fs.writeFile(path.join(resourcesPath, "Assets.car"), assetCatalog)
563+
}
515564
}
516-
appPlist.CFBundleName = appInfo.productName
517-
appPlist.CFBundleDisplayName = appInfo.productName
518565

519566
const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion
520567
if (minimumSystemVersion != null) {

packages/app-builder-lib/src/options/macOptions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export interface MacConfiguration extends PlatformSpecificBuildOptions {
2828

2929
/**
3030
* The path to application icon.
31+
* Accepts `.icns` (legacy) or `.icon` (Icon Composer asset).
32+
* If a `.icon` asset is provided, it will be preferred and compiled to an asset catalog.
3133
* @default build/icon.icns
3234
*/
3335
readonly icon?: string | null

packages/app-builder-lib/src/platformPackager.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { readdir } from "fs/promises"
2020
import { Lazy } from "lazy-val"
2121
import { Minimatch } from "minimatch"
2222
import * as path from "path"
23+
import * as fs from "fs/promises"
24+
import * as os from "os"
2325
import { AppInfo } from "./appInfo"
2426
import { checkFileInArchive } from "./asar/asarFileChecker"
2527
import { AsarPackager } from "./asar/asarUtil"
@@ -46,6 +48,7 @@ import {
4648
import { executeAppBuilderAsJson } from "./util/appBuilder"
4749
import { computeFileSets, computeNodeModuleFileSets, copyAppFiles, ELECTRON_COMPILE_SHIM_FILENAME, transformFiles } from "./util/appFileCopier"
4850
import { expandMacro as doExpandMacro } from "./util/macroExpander"
51+
import { AssetCatalogResult, generateAssetCatalogForIcon } from "./util/macosIconComposer"
4952

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

785+
private assetCatalogResults = new Map<string, Promise<AssetCatalogResult>>()
786+
protected generateAssetCatalogData(iconPath: string): Promise<AssetCatalogResult> {
787+
// Cache results
788+
const cachedPromise = this.assetCatalogResults.get(iconPath)
789+
if (cachedPromise) {
790+
return cachedPromise
791+
}
792+
793+
const promise = generateAssetCatalogForIcon(iconPath)
794+
this.assetCatalogResults.set(iconPath, promise)
795+
return promise
796+
}
797+
798+
private cachedIcnsFromIconFile = new Map<string, Promise<string>>()
799+
private async generateIcnsFromIcon(iconPath: string): Promise<string> {
800+
const cachedPromise = this.cachedIcnsFromIconFile.get(iconPath)
801+
if (cachedPromise) {
802+
return cachedPromise
803+
}
804+
805+
const runner = async () => {
806+
const { icnsFile } = await this.generateAssetCatalogData(iconPath)
807+
808+
// Generate icns file
809+
const tempDir = await fs.mkdtemp(path.resolve(os.tmpdir(), "icon-compile-"))
810+
const tempIcnsPath = path.resolve(tempDir, "Icon.icns")
811+
await fs.writeFile(tempIcnsPath, icnsFile)
812+
813+
return tempIcnsPath
814+
}
815+
const promise = runner()
816+
this.cachedIcnsFromIconFile.set(iconPath, promise)
817+
return promise
818+
}
819+
782820
protected async getOrConvertIcon(format: IconFormat): Promise<string | null> {
821+
if (format === "icns") {
822+
const configuredIcon = this.platformSpecificBuildOptions.icon
823+
// If it is a .icon file, generate the icns file and return the path to the icns file
824+
if (configuredIcon?.endsWith(".icon")) {
825+
const iconPath = await this.getResource(configuredIcon)
826+
if (iconPath) {
827+
return this.generateIcnsFromIcon(iconPath)
828+
}
829+
}
830+
}
831+
783832
const result = await this.resolveIcon(asArray(this.platformSpecificBuildOptions.icon || this.config.icon), [], format)
784833
if (result.length === 0) {
785834
const framework = this.info.framework
@@ -814,9 +863,17 @@ export abstract class PlatformPackager<DC extends PlatformSpecificBuildOptions>
814863
path.resolve(this.projectDir, output, `.icon-${outputFormat}`),
815864
]
816865
for (const source of sources) {
866+
if (source.endsWith(".icon")) {
867+
// Ignore .icon files: they will cause the format conversion to fail
868+
continue
869+
}
817870
args.push("--input", source)
818871
}
819872
for (const source of fallbackSources) {
873+
if (source.endsWith(".icon")) {
874+
// Ignore .icon files: they will cause the format conversion to fail
875+
continue
876+
}
820877
args.push("--fallback-input", source)
821878
}
822879

packages/app-builder-lib/src/targets/LinuxTargetHelper.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ export class LinuxTargetHelper {
6969
// need to put here and not as default because need to resolve image size
7070
const result = await packager.resolveIcon(sources, fallbackSources, "set")
7171
this.maxIconPath = result[result.length - 1].file
72-
return result
72+
73+
// Ignore .icon files for linux (they are exclusive for macOS)
74+
return result.filter(icon => !icon.file.endsWith(".icon"))
7375
}
7476

7577
getDescription(options: LinuxTargetSpecificOptions) {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Adapted from https://github.com/electron/packager/pull/1806
2+
3+
import { spawn } from "builder-util"
4+
import * as fs from "fs/promises"
5+
import * as os from "node:os"
6+
import * as path from "node:path"
7+
import * as plist from "plist"
8+
import * as semver from "semver"
9+
10+
export interface AssetCatalogResult {
11+
assetCatalog: Buffer
12+
icnsFile: Buffer
13+
}
14+
15+
const INVALID_ACTOOL_VERSION_ERROR = new Error(
16+
"Failed to check actool version. Is Xcode 26 or higher installed? See output of the `actool --version` CLI command for more details."
17+
)
18+
19+
async function checkActoolVersion(tmpDir: string) {
20+
const acToolOutputFileName = path.resolve(tmpDir, "actool.log")
21+
22+
let versionInfo: Record<string, Record<string, string>> | undefined = undefined
23+
24+
try {
25+
const acToolOutputFile = await fs.open(acToolOutputFileName, "w")
26+
await spawn("actool", ["--version"], { stdio: ["ignore", acToolOutputFile.fd, acToolOutputFile.fd] })
27+
const acToolVersionOutput = await fs.readFile(acToolOutputFileName, "utf8")
28+
versionInfo = plist.parse(acToolVersionOutput) as Record<string, Record<string, string>>
29+
} catch {
30+
throw INVALID_ACTOOL_VERSION_ERROR
31+
}
32+
33+
if (!versionInfo || !versionInfo["com.apple.actool.version"] || !versionInfo["com.apple.actool.version"]["short-bundle-version"]) {
34+
throw INVALID_ACTOOL_VERSION_ERROR
35+
}
36+
37+
const acToolVersion = versionInfo["com.apple.actool.version"]["short-bundle-version"]
38+
if (!semver.gte(semver.coerce(acToolVersion)!, "26.0.0")) {
39+
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.`)
40+
}
41+
}
42+
43+
/**
44+
* Generates an asset catalog and extra assets that are useful for packaging the app.
45+
* @param inputPath The path to the `.icon` file
46+
* @returns The asset catalog and extra assets
47+
*/
48+
export async function generateAssetCatalogForIcon(inputPath: string): Promise<AssetCatalogResult> {
49+
const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), "icon-compile-"))
50+
const cleanup = async () => {
51+
await fs.rm(tmpDir, {
52+
recursive: true,
53+
force: true,
54+
})
55+
}
56+
57+
try {
58+
await checkActoolVersion(tmpDir)
59+
} catch (error) {
60+
await cleanup()
61+
throw error
62+
}
63+
64+
const iconPath = path.resolve(tmpDir, "Icon.icon")
65+
const outputPath = path.resolve(tmpDir, "out")
66+
67+
try {
68+
await fs.cp(inputPath, iconPath, {
69+
recursive: true,
70+
})
71+
72+
await fs.mkdir(outputPath, {
73+
recursive: true,
74+
})
75+
76+
await spawn("actool", [
77+
iconPath,
78+
"--compile",
79+
outputPath,
80+
"--output-format",
81+
"human-readable-text",
82+
"--notices",
83+
"--warnings",
84+
"--output-partial-info-plist",
85+
path.resolve(outputPath, "assetcatalog_generated_info.plist"),
86+
"--app-icon",
87+
"Icon",
88+
"--include-all-app-icons",
89+
"--accent-color",
90+
"AccentColor",
91+
"--enable-on-demand-resources",
92+
"NO",
93+
"--development-region",
94+
"en",
95+
"--target-device",
96+
"mac",
97+
"--minimum-deployment-target",
98+
"26.0",
99+
"--platform",
100+
"macosx",
101+
])
102+
103+
const assetCatalog = await fs.readFile(path.resolve(outputPath, "Assets.car"))
104+
const icnsFile = await fs.readFile(path.resolve(outputPath, "Icon.icns"))
105+
106+
return { assetCatalog, icnsFile }
107+
} finally {
108+
await cleanup()
109+
}
110+
}

0 commit comments

Comments
 (0)