Skip to content

Commit ddb4aa6

Browse files
feat: support Icon Composer files as icon input for macOS (#1806)
* feat: support Icon Composer files as icon input for macOS * chore: update for feedback
1 parent 85e52b1 commit ddb4aa6

File tree

5 files changed

+151
-24
lines changed

5 files changed

+151
-24
lines changed

src/icon-composer.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { spawn } from '@malept/cross-spawn-promise';
2+
import fs from 'node:fs/promises';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
6+
import plist from 'plist';
7+
import semver from 'semver';
8+
9+
export async function generateAssetCatalogForIcon(inputPath: string) {
10+
if (!semver.gte(os.version(), '25.0.0')) {
11+
throw new Error(
12+
`actool .icon support is currently limited to macOS 26 and higher`
13+
);
14+
}
15+
16+
const acToolVersionOutput = await spawn('actool', ['--version']);
17+
const versionInfo = plist.parse(acToolVersionOutput) as Record<
18+
string,
19+
Record<string, string>
20+
>;
21+
if (
22+
!versionInfo ||
23+
!versionInfo['com.apple.actool.version'] ||
24+
!versionInfo['com.apple.actool.version']['short-bundle-version']
25+
) {
26+
throw new Error(
27+
'Unable to query actool version. Is Xcode 26 or higher installed? See output of the `actool --version` CLI command for more details.',
28+
);
29+
}
30+
31+
const acToolVersion =
32+
versionInfo['com.apple.actool.version']['short-bundle-version'];
33+
if (!semver.gte(semver.coerce(acToolVersion)!, '26.0.0')) {
34+
throw new Error(
35+
`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.`,
36+
);
37+
}
38+
39+
const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'icon-compile-'));
40+
const iconPath = path.resolve(tmpDir, 'Icon.icon');
41+
const outputPath = path.resolve(tmpDir, 'out');
42+
43+
try {
44+
await fs.cp(inputPath, iconPath, {
45+
recursive: true,
46+
});
47+
48+
await fs.mkdir(outputPath, {
49+
recursive: true,
50+
});
51+
52+
await spawn('actool', [
53+
iconPath,
54+
'--compile',
55+
outputPath,
56+
'--output-format',
57+
'human-readable-text',
58+
'--notices',
59+
'--warnings',
60+
'--output-partial-info-plist',
61+
path.resolve(outputPath, 'assetcatalog_generated_info.plist'),
62+
'--app-icon',
63+
'Icon',
64+
'--include-all-app-icons',
65+
'--accent-color',
66+
'AccentColor',
67+
'--enable-on-demand-resources',
68+
'NO',
69+
'--development-region',
70+
'en',
71+
'--target-device',
72+
'mac',
73+
'--minimum-deployment-target',
74+
'26.0',
75+
'--platform',
76+
'macosx',
77+
]);
78+
79+
return await fs.readFile(path.resolve(outputPath, 'Assets.car'));
80+
} finally {
81+
await fs.rm(tmpDir, {
82+
recursive: true,
83+
force: true,
84+
});
85+
}
86+
}

src/mac.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { notarize, NotarizeOptions } from '@electron/notarize';
77
import { signApp } from '@electron/osx-sign';
88
import { ComboOptions } from './types';
99
import { SignOptions } from '@electron/osx-sign/dist/cjs/types';
10+
import { generateAssetCatalogForIcon } from './icon-composer';
1011

1112
type NSUsageDescription = {
1213
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -25,6 +26,7 @@ type BasePList = {
2526
interface Plists {
2627
appPlist?: BasePList & {
2728
CFBundleIconFile: string;
29+
CFBundleIconName: string;
2830
// eslint-disable-next-line no-use-before-define
2931
CFBundleURLTypes: MacApp['protocols'];
3032
ElectronAsarIntegrity: App['asarIntegrity'];
@@ -380,6 +382,10 @@ export class MacApp extends App implements Plists {
380382
}
381383
}
382384

385+
// Copying the icon compose icon mutates the appPlist so must
386+
// be run before we update plist files
387+
await this.copyIconComposerIcon(this.appPlist);
388+
383389
await Promise.all(
384390
plists.map(([filename, varName]) =>
385391
fs.writeFile(
@@ -447,19 +453,42 @@ export class MacApp extends App implements Plists {
447453
);
448454
}
449455

456+
async copyIconComposerIcon(appPlist: NonNullable<Plists['appPlist']>) {
457+
if (!this.opts.icon) {
458+
return;
459+
}
460+
461+
let iconComposerIcon: string | null = null;
462+
463+
try {
464+
iconComposerIcon = (await this.normalizeIconExtension('.icon')) || null;
465+
} catch {
466+
// Ignore error if icon doesn't exist, in case only the .icns format was provided
467+
}
468+
if (iconComposerIcon) {
469+
debug(
470+
`Generating asset catalog for icon composer "${iconComposerIcon}" file`,
471+
);
472+
const assetCatalog = await generateAssetCatalogForIcon(iconComposerIcon);
473+
appPlist.CFBundleIconName = 'Icon';
474+
await fs.writeFile(
475+
path.join(this.originalResourcesDir, 'Assets.car'),
476+
assetCatalog,
477+
);
478+
}
479+
}
480+
450481
async copyIcon() {
451482
if (!this.opts.icon) {
452483
return Promise.resolve();
453484
}
454485

455-
let icon;
486+
let icon: string | null = null;
456487

457488
try {
458-
icon = await this.normalizeIconExtension('.icns');
489+
icon = (await this.normalizeIconExtension('.icns')) || null;
459490
} catch {
460491
// Ignore error if icon doesn't exist, in case it's only available for other OSes
461-
/* istanbul ignore next */
462-
return Promise.resolve();
463492
}
464493
if (icon) {
465494
debug(
@@ -540,6 +569,7 @@ export class MacApp extends App implements Plists {
540569
async create() {
541570
await this.initialize();
542571
await this.updatePlistFiles();
572+
// Copying icons depends on the plist files being updated
543573
await this.copyIcon();
544574
await this.renameElectron();
545575
await this.renameAppAndHelpers();

src/platform.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -195,29 +195,33 @@ export class App {
195195
*
196196
* This error path is used by win32 if no icon is specified.
197197
*/
198-
async normalizeIconExtension(targetExt: string) {
198+
async normalizeIconExtension(targetExt: string): Promise<string | void> {
199199
if (!this.opts.icon) {
200200
throw new Error('No filename specified to normalizeIconExtension');
201201
}
202202

203-
let iconFilename = this.opts.icon;
204-
const ext = path.extname(iconFilename);
205-
if (ext !== targetExt) {
206-
iconFilename = path.join(
207-
path.dirname(iconFilename),
208-
path.basename(iconFilename, ext) + targetExt,
209-
);
210-
}
203+
const iconFilenames = Array.isArray(this.opts.icon)
204+
? this.opts.icon
205+
: [this.opts.icon];
206+
for (let iconFilename of iconFilenames) {
207+
const ext = path.extname(iconFilename);
208+
if (ext !== targetExt) {
209+
iconFilename = path.join(
210+
path.dirname(iconFilename),
211+
path.basename(iconFilename, ext) + targetExt,
212+
);
213+
}
211214

212-
if (await fs.pathExists(iconFilename)) {
213-
return iconFilename;
214-
} else {
215-
/* istanbul ignore next */
216-
warning(
217-
`Could not find icon "${iconFilename}", not updating app icon`,
218-
this.opts.quiet,
219-
);
215+
if (await fs.pathExists(iconFilename)) {
216+
return iconFilename;
217+
}
220218
}
219+
220+
/* istanbul ignore next */
221+
warning(
222+
`Could not find icon "${this.opts.icon}" with extension "${targetExt}", skipping this app icon format`,
223+
this.opts.quiet,
224+
);
221225
}
222226

223227
prebuiltAsarWarning(option: keyof ComboOptions, triggerWarning: unknown) {

src/types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,10 +434,14 @@ export interface Options {
434434
helperBundleId?: string;
435435
/**
436436
* The local path to the icon file, if the target platform supports setting embedding an icon.
437+
*
438+
* Only macOS supports multiple paths, every other platform must be a single path. On macOS you can provide
439+
* **both** an `.icns` and an `.icon` file. The `.icns` file will be used on macOS < 26 and `.icon` will be used
440+
* on macOS >= 26.
437441
*
438442
* Currently you must look for conversion tools in order to supply an icon in the format required by the platform:
439443
*
440-
* - macOS: `.icns`
444+
* - macOS: `.icon` from Icon Composer (only supported if Xcode 26 or higher is present) or `.icns`
441445
* - Windows: `.ico` ([See the readme](https://github.com/electron/packager#building-windows-apps-from-non-windows-platforms) for details on non-Windows platforms)
442446
* - Linux: this option is not supported, as the dock/window list icon is set via
443447
* [the `icon` option in the `BrowserWindow` constructor](https://electronjs.org/docs/api/browser-window/#new-browserwindowoptions).
@@ -447,7 +451,7 @@ export interface Options {
447451
* If the file extension is omitted, it is auto-completed to the correct extension based on the
448452
* platform, including when {@link platform |`platform: 'all'`} is in effect.
449453
*/
450-
icon?: string;
454+
icon?: string | string[];
451455
/**
452456
* One or more additional [regular expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions)
453457
* patterns which specify which files to ignore when copying files to create the app bundle(s). The

src/win32.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,13 @@ export class WindowsApp extends App {
3838
};
3939
}
4040

41-
async getIconPath() {
41+
async getIconPath(): Promise<string | void> {
4242
if (!this.opts.icon) {
4343
return Promise.resolve();
4444
}
45+
if (Array.isArray(this.opts.icon)) {
46+
throw new Error('opts.path must be a single path on Windows');
47+
}
4548

4649
return this.normalizeIconExtension('.ico');
4750
}

0 commit comments

Comments
 (0)