feat(v3): Support for Icon Composer Liquid Glass Icons (macOS) (#4934)

* feat(icons): implement Mac asset (.car) generation with actool

- Check actool version >= 26 requirement
- Generate asset.car from Icon Composer input
- Validate compilation output and cleanup temp files

* Wails Icon as Icon Composer file

* a generated assets.car from the wails icon

* handle absolute paths correctly in actool command

- Check if paths are absolute before prepending "./"
- Use filepath.Join for temp.plist path construction

* add test for Assets.car generation

* Skipping Asset.car generation and test on non mac-systems

* add CFBundleIconName generation to plist, if Assets.car exists

* also create .icns from .icon-File and use always absolut path

Use absolut path, because otherwise we got strange behavior from actool.

* update to use appicon as CFBundleIconName and optionally use the name from config

* update the Taskfiles

* remove log prints

* the awesome new LiquidGlass icon files

* update doc

* Update UNRELEASED_CHANGELOG.md

* Update UNRELEASED_CHANGELOG.md

* fix security bug

* Skip icon generation test with actool on CI

* fix error from coderabbitai

* solved the coderabbitai nitpicks

* fix coderabbitai findings

* Update changelog

---------

Co-authored-by: Lea Anthony <lea.anthony@gmail.com>
This commit is contained in:
Wilko 2026-02-01 00:07:56 +01:00 committed by GitHub
commit 2db6a1c427
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 580 additions and 29 deletions

View file

@ -17,7 +17,7 @@ wails3 package GOOS=darwin
This creates `bin/<AppName>.app` containing:
- The compiled binary in `Contents/MacOS/`
- App icon in `Contents/Resources/`
- App icon in `Contents/Resources/` (from `icons.icns` or, when present, from an asset catalog `Assets.car`)
- `Info.plist` with app metadata
### Universal Binary
@ -40,10 +40,23 @@ Edit `build/darwin/Info.plist` to customize:
- File associations
- URL schemes
The app icon is generated from `build/appicon.png`. Regenerate with:
The app icon is generated from assets in the `build/` directory. Use the `generate:icons` task:
```bash
wails3 generate icons -input build/appicon.png
wails3 task generate:icons
```
This uses `build/appicon.png` to produce `darwin/icons.icns` and `windows/icon.ico`. On macOS you can also provide `build/appicon.icon` (Icon Composer format): the task passes `-iconcomposerinput appicon.icon -macassetdir darwin`, which produces `Assets.car` and `darwin/icons.icns` from the `.icon` file (skipped on non-macOS platforms). When `Assets.car` is present, run the `update:build-assets` task so that `Info.plist` and `CFBundleIconName` are updated accordingly:
```bash
wails3 task update:build-assets
```
To run the icon command manually from the `build/` directory:
```bash
cd build
wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
```
## Code Signing

View file

@ -16,7 +16,7 @@ After processing, the content will be moved to the main changelog and this file
-->
## Added
<!-- New features, capabilities, or enhancements -->
- Add support for using `.icon` files (Apple Icon Composer format) for generating Liquid Glass icons and asset catalogs (macOS) (#4934) by @wimaha
## Changed
<!-- Changes in existing functionality -->

View file

@ -42,6 +42,7 @@ type BuildAssetsOptions struct {
ProductCopyright string `description:"The copyright notice" default:"\u00a9 now, My Company"`
ProductComments string `description:"Comments to add to the generated files" default:"This is a comment"`
ProductIdentifier string `description:"The product identifier, e.g com.mycompany.myproduct"`
CFBundleIconName string `description:"The macOS icon name (for Assets.car icon bundles)"`
Publisher string `description:"Publisher name for MSIX package (e.g., CN=CompanyName)"`
ProcessorArchitecture string `description:"Processor architecture for MSIX package" default:"x64"`
ExecutablePath string `description:"Path to executable for MSIX package"`
@ -71,6 +72,7 @@ type UpdateBuildAssetsOptions struct {
ProductCopyright string `description:"The copyright notice" default:"© now, My Company"`
ProductComments string `description:"Comments to add to the generated files" default:"This is a comment"`
ProductIdentifier string `description:"The product identifier, e.g com.mycompany.myproduct"`
CFBundleIconName string `description:"The macOS icon name (for Assets.car icon bundles)"`
Config string `description:"The path to the config file"`
Silent bool `description:"Suppress output to console"`
}
@ -146,10 +148,17 @@ func GenerateBuildAssets(options *BuildAssetsOptions) error {
if err != nil {
return err
}
// Check if Assets.car exists - if so, set CFBundleIconName if not already set
// This must happen BEFORE the updatable_build_assets extraction so CFBundleIconName is available in Info.plist templates
checkAndSetCFBundleIconNameCommon(options.Dir, &buildCFBundleIconNameSetter{options, &config})
// Update config with the potentially modified options
config.BuildAssetsOptions = *options
tfs, err = fs.Sub(updatableBuildAssets, "updatable_build_assets")
if err != nil {
return err
}
err = gosod.New(tfs).Extract(options.Dir, config)
if err != nil {
return err
@ -185,6 +194,7 @@ type WailsConfig struct {
Copyright string `yaml:"copyright"`
Comments string `yaml:"comments"`
Version string `yaml:"version"`
CFBundleIconName string `yaml:"cfBundleIconName,omitempty"`
} `yaml:"info"`
FileAssociations []FileAssociation `yaml:"fileAssociations,omitempty"`
Protocols []ProtocolConfig `yaml:"protocols,omitempty"`
@ -233,6 +243,9 @@ func UpdateBuildAssets(options *UpdateBuildAssetsOptions) error {
if options.ProductVersion == "0.1.0" && wailsConfig.Info.Version != "" {
options.ProductVersion = wailsConfig.Info.Version
}
if options.CFBundleIconName == "" && wailsConfig.Info.CFBundleIconName != "" {
options.CFBundleIconName = wailsConfig.Info.CFBundleIconName
}
config.FileAssociations = wailsConfig.FileAssociations
config.Protocols = wailsConfig.Protocols
}
@ -247,6 +260,11 @@ func UpdateBuildAssets(options *UpdateBuildAssetsOptions) error {
}
}
// Check if Assets.car exists - if so, set CFBundleIconName if not already set
checkAndSetCFBundleIconNameCommon(options.Dir, &updateCFBundleIconNameSetter{options, &config})
// Update config with the potentially modified options
config.UpdateBuildAssetsOptions = *options
tfs, err := fs.Sub(updatableBuildAssets, "updatable_build_assets")
if err != nil {
return err
@ -284,6 +302,51 @@ func normaliseName(name string) string {
return strings.ToLower(strings.ReplaceAll(name, " ", "-"))
}
// CFBundleIconNameSetter is implemented by types that can get and set CFBundleIconName
// (used to keep options and config in sync when defaulting the macOS icon name).
type CFBundleIconNameSetter interface {
GetCFBundleIconName() string
SetCFBundleIconName(string)
}
// checkAndSetCFBundleIconNameCommon checks if Assets.car exists in the darwin folder
// and sets CFBundleIconName via setter if not already set. The icon name should be configured
// in config.yml under info.cfBundleIconName and should match the name of the .icon file without the extension
// with which Assets.car was generated. If not set, defaults to "appicon".
func checkAndSetCFBundleIconNameCommon(dir string, setter CFBundleIconNameSetter) {
darwinDir := filepath.Join(dir, "darwin")
assetsCarPath := filepath.Join(darwinDir, "Assets.car")
if _, err := os.Stat(assetsCarPath); err == nil {
if setter.GetCFBundleIconName() == "" {
setter.SetCFBundleIconName("appicon")
}
}
}
// buildCFBundleIconNameSetter sets CFBundleIconName on both options and config for GenerateBuildAssets.
type buildCFBundleIconNameSetter struct {
options *BuildAssetsOptions
config *BuildConfig
}
func (s *buildCFBundleIconNameSetter) GetCFBundleIconName() string { return s.options.CFBundleIconName }
func (s *buildCFBundleIconNameSetter) SetCFBundleIconName(v string) {
s.options.CFBundleIconName = v
s.config.CFBundleIconName = v
}
// updateCFBundleIconNameSetter sets CFBundleIconName on both options and config for UpdateBuildAssets.
type updateCFBundleIconNameSetter struct {
options *UpdateBuildAssetsOptions
config *UpdateConfig
}
func (s *updateCFBundleIconNameSetter) GetCFBundleIconName() string { return s.options.CFBundleIconName }
func (s *updateCFBundleIconNameSetter) SetCFBundleIconName(v string) {
s.options.CFBundleIconName = v
s.config.CFBundleIconName = v
}
// mergeMaps recursively merges src into dst.
// For nested maps, it merges recursively. For other types, src overwrites dst.
func mergeMaps(dst, src map[string]any) {

View file

@ -168,6 +168,7 @@ func TestUpdateBuildAssets(t *testing.T) {
Copyright string `yaml:"copyright"`
Comments string `yaml:"comments"`
Version string `yaml:"version"`
CFBundleIconName string `yaml:"cfBundleIconName,omitempty"`
}{
CompanyName: "Config Company",
ProductName: "Config Product",
@ -351,6 +352,161 @@ func TestPlistMerge(t *testing.T) {
}
}
func TestCFBundleIconNameDetection(t *testing.T) {
tempDir, err := os.MkdirTemp("", "wails-icon-name-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
tests := []struct {
name string
createAssetsCar bool
configIconName string
expectedIconName string
expectIconNameInPlist bool
}{
{
name: "Assets.car exists, no config - should default to appicon",
createAssetsCar: true,
configIconName: "",
expectedIconName: "appicon",
expectIconNameInPlist: true,
},
{
name: "Assets.car exists, config set - should use config",
createAssetsCar: true,
configIconName: "custom-icon",
expectedIconName: "custom-icon",
expectIconNameInPlist: true,
},
{
name: "No Assets.car, no config - should not set",
createAssetsCar: false,
configIconName: "",
expectedIconName: "",
expectIconNameInPlist: false,
},
{
name: "No Assets.car, config set - should use config",
createAssetsCar: false,
configIconName: "config-icon",
expectedIconName: "config-icon",
expectIconNameInPlist: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buildDir := filepath.Join(tempDir, tt.name)
darwinDir := filepath.Join(buildDir, "darwin")
err := os.MkdirAll(darwinDir, 0755)
if err != nil {
t.Fatalf("Failed to create darwin directory: %v", err)
}
// Create Assets.car BEFORE calling UpdateBuildAssets if needed
// The check happens before template extraction, so CFBundleIconName will be available in the template
if tt.createAssetsCar {
assetsCarPath := filepath.Join(darwinDir, "Assets.car")
err = os.WriteFile(assetsCarPath, []byte("fake assets.car content"), 0644)
if err != nil {
t.Fatalf("Failed to create Assets.car: %v", err)
}
}
// Create config file if icon name is set
configFile := ""
if tt.configIconName != "" {
configDir := filepath.Join(tempDir, "config-"+tt.name)
err = os.MkdirAll(configDir, 0755)
if err != nil {
t.Fatalf("Failed to create config directory: %v", err)
}
configFile = filepath.Join(configDir, "wails.yaml")
config := WailsConfig{
Info: struct {
CompanyName string `yaml:"companyName"`
ProductName string `yaml:"productName"`
ProductIdentifier string `yaml:"productIdentifier"`
Description string `yaml:"description"`
Copyright string `yaml:"copyright"`
Comments string `yaml:"comments"`
Version string `yaml:"version"`
CFBundleIconName string `yaml:"cfBundleIconName,omitempty"`
}{
CompanyName: "Test Company",
ProductName: "Test Product",
ProductIdentifier: "com.test.product",
CFBundleIconName: tt.configIconName,
},
}
configBytes, err := yaml.Marshal(config)
if err != nil {
t.Fatalf("Failed to marshal config: %v", err)
}
err = os.WriteFile(configFile, configBytes, 0644)
if err != nil {
t.Fatalf("Failed to write config file: %v", err)
}
}
options := &UpdateBuildAssetsOptions{
Dir: buildDir,
Name: "TestApp",
ProductName: "Test App",
ProductVersion: "1.0.0",
ProductCompany: "Test Company",
ProductIdentifier: "com.test.app",
CFBundleIconName: tt.configIconName,
Config: configFile,
Silent: true,
}
err = UpdateBuildAssets(options)
if err != nil {
t.Fatalf("UpdateBuildAssets failed: %v", err)
}
// Verify CFBundleIconName was set correctly in options
if options.CFBundleIconName != tt.expectedIconName {
t.Errorf("Expected CFBundleIconName to be '%s', got '%s'", tt.expectedIconName, options.CFBundleIconName)
}
// Check Info.plist if it exists
infoPlistPath := filepath.Join(darwinDir, "Info.plist")
if _, err := os.Stat(infoPlistPath); err == nil {
plistContent, err := os.ReadFile(infoPlistPath)
if err != nil {
t.Fatalf("Failed to read Info.plist: %v", err)
}
var plistDict map[string]any
_, err = plist.Unmarshal(plistContent, &plistDict)
if err != nil {
t.Fatalf("Failed to parse Info.plist: %v", err)
}
iconName, exists := plistDict["CFBundleIconName"]
if tt.expectIconNameInPlist {
if !exists {
t.Errorf("Expected CFBundleIconName to be present in Info.plist")
} else if iconName != tt.expectedIconName {
t.Errorf("Expected CFBundleIconName in Info.plist to be '%s', got '%v'", tt.expectedIconName, iconName)
}
} else {
if exists {
t.Errorf("Expected CFBundleIconName to not be present in Info.plist, but found '%v'", iconName)
}
}
}
})
}
}
func TestNestedPlistMerge(t *testing.T) {
tests := []struct {
name string

View file

@ -97,15 +97,16 @@ tasks:
- wails3 generate bindings -f {{ "'{{.BUILD_FLAGS}}'" }} -clean=true {{- if .Typescript}} -ts{{end}}
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` files from an image
summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
dir: build
sources:
- "appicon.png"
- "appicon.icon"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
dev:frontend:
summary: Runs the frontend in development mode

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 583 533" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-246,-251)">
<g id="Ebene1">
<path d="M246,251L265,784L401,784L506,450L507,450L505,784L641,784L829,251L682,251L596,567L595,567L596,251L478,251L378,568L391,251L246,251Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 698 B

View file

@ -0,0 +1,51 @@
{
"fill" : {
"automatic-gradient" : "extended-gray:1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"fill-specializations" : [
{
"appearance" : "dark",
"value" : {
"solid" : "srgb:0.92143,0.92145,0.92144,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "srgb:0.83742,0.83744,0.83743,1.00000"
}
}
],
"image-name" : "wails_icon_vector.svg",
"name" : "wails_icon_vector",
"position" : {
"scale" : 1.25,
"translation-in-points" : [
36.890625,
4.96875
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View file

@ -12,6 +12,9 @@ info:
copyright: "(c) 2025, My Company" # Copyright text
comments: "Some Product Comments" # Comments
version: "0.0.1" # The application version
# cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
# # Should match the name of your .icon file without the extension
# # If not set and Assets.car exists, defaults to "appicon"
# iOS build configuration (uncomment to customise iOS project generation)
# Note: Keys under `ios` OVERRIDE values under `info` when set.

Binary file not shown.

View file

@ -141,6 +141,10 @@ tasks:
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
- |
if [ -f build/darwin/Assets.car ]; then
cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
fi
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
- task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
@ -162,6 +166,10 @@ tasks:
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- |
if [ -f build/darwin/Assets.car ]; then
cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
fi
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"

View file

@ -2,24 +2,36 @@ package commands
import (
"bytes"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/jackmordaunt/icns/v2"
"github.com/leaanthony/winicon"
"github.com/wailsapp/wails/v3/internal/operatingsystem"
"howett.net/plist"
)
// ErrMacAssetNotSupported is returned by generateMacAsset when mac asset generation
// is not supported on the current platform (e.g., non-macOS systems).
var ErrMacAssetNotSupported = errors.New("mac asset generation is only supported on macOS")
type IconsOptions struct {
Example bool `description:"Generate example icon file (appicon.png) in the current directory"`
Input string `description:"The input image file"`
Sizes string `description:"The sizes to generate in .ico file (comma separated)" default:"256,128,64,48,32,16"`
WindowsFilename string `description:"The output filename for the Windows icon" default:"icon.ico"`
MacFilename string `description:"The output filename for the Mac icon bundle" default:"icons.icns"`
Example bool `description:"Generate example icon file (appicon.png) in the current directory"`
Input string `description:"The input image file"`
Sizes string `description:"The sizes to generate in .ico file (comma separated)" default:"256,128,64,48,32,16"`
WindowsFilename string `description:"The output filename for the Windows icon"`
MacFilename string `description:"The output filename for the Mac icon bundle"`
IconComposerInput string `description:"The input Icon Composer file (.icon)"`
MacAssetDir string `description:"The output directory for the Mac assets (Assets.car and icons.icns)"`
}
func GenerateIcons(options *IconsOptions) error {
@ -29,12 +41,16 @@ func GenerateIcons(options *IconsOptions) error {
return generateExampleIcon()
}
if options.Input == "" {
return fmt.Errorf("input is required")
if options.Input == "" && options.IconComposerInput == "" {
return fmt.Errorf("either input or icon composer input is required")
}
if options.WindowsFilename == "" && options.MacFilename == "" {
return fmt.Errorf("at least one output filename is required")
if options.Input != "" && options.WindowsFilename == "" && options.MacFilename == "" {
return fmt.Errorf("either windows filename or mac filename is required")
}
if options.IconComposerInput != "" && options.MacAssetDir == "" {
return fmt.Errorf("mac asset directory is required")
}
// Parse sizes
@ -46,23 +62,49 @@ func GenerateIcons(options *IconsOptions) error {
return err
}
}
iconData, err := os.ReadFile(options.Input)
if err != nil {
return err
}
if options.WindowsFilename != "" {
err := generateWindowsIcon(iconData, sizes, options)
if err != nil {
return err
// Generate Icons from Icon Composer input
macIconsGenerated := false
if options.IconComposerInput != "" {
if options.MacAssetDir != "" {
err := generateMacAsset(options)
if err != nil {
if errors.Is(err, ErrMacAssetNotSupported) {
// No fallback: Icon Composer path requires macOS; return so callers see unsupported-platform failure
if options.Input == "" {
return fmt.Errorf("icon composer input requires macOS for mac asset generation: %w", err)
}
// Fallback to input-based generation will run below
} else {
return err
}
} else {
macIconsGenerated = true
}
}
}
if options.MacFilename != "" {
err := generateMacIcon(iconData, options)
// Generate Icons from input image
if options.Input != "" {
iconData, err := os.ReadFile(options.Input)
if err != nil {
return err
}
if options.WindowsFilename != "" {
err := generateWindowsIcon(iconData, sizes, options)
if err != nil {
return err
}
}
// Generate Icons from input image if no Mac icons were generated from Icon Composer input
if options.MacFilename != "" && !macIconsGenerated {
err := generateMacIcon(iconData, options)
if err != nil {
return err
}
}
}
return nil
@ -116,6 +158,150 @@ func generateMacIcon(iconData []byte, options *IconsOptions) error {
return icns.Encode(dest, srcImg)
}
func generateMacAsset(options *IconsOptions) error {
//Check if running on darwin (macOS), because this will only run on a mac
if runtime.GOOS != "darwin" {
return ErrMacAssetNotSupported
}
// Get system info, because this will only run on macOS 26 or later
info, err := operatingsystem.Info()
if err != nil {
return ErrMacAssetNotSupported
}
majorStr, _, found := strings.Cut(info.Version, ".")
if !found {
return ErrMacAssetNotSupported
}
major, err := strconv.Atoi(majorStr)
if err != nil {
return ErrMacAssetNotSupported
}
if major < 26 {
return ErrMacAssetNotSupported
}
cmd := exec.Command("/usr/bin/actool", "--version")
versionPlist, err := cmd.Output()
if err != nil {
return ErrMacAssetNotSupported
}
// Parse the plist to extract short-bundle-version
var plistData map[string]any
if _, err := plist.Unmarshal(versionPlist, &plistData); err != nil {
return ErrMacAssetNotSupported
}
// Navigate to com.apple.actool.version -> short-bundle-version
actoolVersion, ok := plistData["com.apple.actool.version"].(map[string]any)
if !ok {
return ErrMacAssetNotSupported
}
shortVersion, ok := actoolVersion["short-bundle-version"].(string)
if !ok {
return ErrMacAssetNotSupported
}
// Parse the major version number (e.g., "26.2" -> 26)
actoolMajorStr, _, _ := strings.Cut(shortVersion, ".")
actoolMajor, err := strconv.Atoi(actoolMajorStr)
if err != nil {
return ErrMacAssetNotSupported
}
if actoolMajor < 26 {
return ErrMacAssetNotSupported
}
// Convert paths to absolute paths (required for actool)
iconComposerPath, err := filepath.Abs(options.IconComposerInput)
if err != nil {
return fmt.Errorf("failed to get absolute path for icon composer input: %w", err)
}
macAssetDirPath, err := filepath.Abs(options.MacAssetDir)
if err != nil {
return fmt.Errorf("failed to get absolute path for mac asset directory: %w", err)
}
// Get Filename from Icon Composer input without extension
iconComposerFilename := filepath.Base(iconComposerPath)
iconComposerFilename = strings.TrimSuffix(iconComposerFilename, filepath.Ext(iconComposerFilename))
cmd = exec.Command("/usr/bin/actool", iconComposerPath,
"--compile", macAssetDirPath,
"--notices", "--warnings", "--errors",
"--output-partial-info-plist", filepath.Join(macAssetDirPath, "temp.plist"),
"--app-icon", iconComposerFilename,
"--enable-on-demand-resources", "NO",
"--development-region", "en",
"--target-device", "mac",
"--minimum-deployment-target", "26.0",
"--platform", "macosx")
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to run actool: %w", err)
}
// Parse the plist output to verify compilation results
var compilationResults map[string]any
if _, err := plist.Unmarshal(out, &compilationResults); err != nil {
return fmt.Errorf("failed to parse actool compilation results: %w", err)
}
// Navigate to com.apple.actool.compilation-results -> output-files
compilationData, ok := compilationResults["com.apple.actool.compilation-results"].(map[string]any)
if !ok {
return fmt.Errorf("failed to find com.apple.actool.compilation-results in plist")
}
outputFiles, ok := compilationData["output-files"].([]any)
if !ok {
return fmt.Errorf("failed to find output-files array in compilation results")
}
// Check that we have one .car file and one .plist file
var carFile, plistFile, icnsFile string
for _, file := range outputFiles {
filePath, ok := file.(string)
if !ok {
return fmt.Errorf("output file is not a string: %v", file)
}
ext := filepath.Ext(filePath)
switch ext {
case ".car":
carFile = filePath
case ".plist":
plistFile = filePath
case ".icns":
icnsFile = filePath
// Ignore other output files that may be added in future actool versions
}
}
if carFile == "" {
return fmt.Errorf("no .car file found in output files")
}
if plistFile == "" {
return fmt.Errorf("no .plist file found in output files")
}
if icnsFile == "" {
return fmt.Errorf("no .icns file found in output files")
}
// Remove the temporary plist file since compilation was successful
if err := os.Remove(plistFile); err != nil {
return fmt.Errorf("failed to remove temporary plist file: %w", err)
}
// Rename the .icns file to icons.icns
if err := os.Rename(icnsFile, filepath.Join(macAssetDirPath, "icons.icns")); err != nil {
return fmt.Errorf("failed to rename .icns file to icons.icns: %w", err)
}
return nil
}
func generateWindowsIcon(iconData []byte, sizes []int, options *IconsOptions) error {
var output bytes.Buffer

View file

@ -10,10 +10,11 @@ import (
func TestGenerateIcon(t *testing.T) {
tests := []struct {
name string
setup func() *IconsOptions
wantErr bool
test func() error
name string
setup func() *IconsOptions
wantErr bool
requireDarwin bool
test func() error
}{
{
name: "should generate an icon when using the `example` flag",
@ -123,6 +124,54 @@ func TestGenerateIcon(t *testing.T) {
return nil
},
},
{
name: "should generate a Assets.car and icons.icns file when using the `IconComposerInput` flag and `MacAssetDir` flag",
requireDarwin: true,
setup: func() *IconsOptions {
// Get the directory of this file
_, thisFile, _, _ := runtime.Caller(1)
localDir := filepath.Dir(thisFile)
// Get the path to the example icon
exampleIcon := filepath.Join(localDir, "build_assets", "appicon.icon")
return &IconsOptions{
IconComposerInput: exampleIcon,
MacAssetDir: localDir,
}
},
wantErr: false,
test: func() error {
_, thisFile, _, _ := runtime.Caller(1)
localDir := filepath.Dir(thisFile)
carPath := filepath.Join(localDir, "Assets.car")
icnsPath := filepath.Join(localDir, "icons.icns")
defer func() {
_ = os.Remove(carPath)
_ = os.Remove(icnsPath)
}()
f, err := os.Stat(carPath)
if err != nil {
return err
}
if f.IsDir() {
return fmt.Errorf("Assets.car is a directory")
}
if f.Size() == 0 {
return fmt.Errorf("Assets.car is empty")
}
f, err = os.Stat(icnsPath)
if err != nil {
return err
}
if f.IsDir() {
return fmt.Errorf("icons.icns is a directory")
}
if f.Size() == 0 {
return fmt.Errorf("icons.icns is empty")
}
return nil
},
},
{
name: "should generate a small .ico file when using the `input` flag and `sizes` flag",
setup: func() *IconsOptions {
@ -266,6 +315,10 @@ func TestGenerateIcon(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.requireDarwin && (runtime.GOOS != "darwin" || os.Getenv("CI") != "") {
t.Skip("Assets.car generation is only supported on macOS and not in CI")
}
options := tt.setup()
err := GenerateIcons(options)
if (err != nil) != tt.wantErr {

View file

@ -17,6 +17,10 @@
<string>{{.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>icons</string>
{{- if .CFBundleIconName}}
<key>CFBundleIconName</key>
<string>{{.CFBundleIconName}}</string>
{{- end}}
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>

View file

@ -17,6 +17,10 @@
<string>{{.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>icons</string>
{{- if .CFBundleIconName}}
<key>CFBundleIconName</key>
<string>{{.CFBundleIconName}}</string>
{{- end}}
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>