diff --git a/docs/src/content/docs/guides/build/macos.mdx b/docs/src/content/docs/guides/build/macos.mdx index 1d820daef..3271b2faa 100644 --- a/docs/src/content/docs/guides/build/macos.mdx +++ b/docs/src/content/docs/guides/build/macos.mdx @@ -17,7 +17,7 @@ wails3 package GOOS=darwin This creates `bin/.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 diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 8e4648038..778f59b06 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -16,7 +16,7 @@ After processing, the content will be moved to the main changelog and this file --> ## Added - +- Add support for using `.icon` files (Apple Icon Composer format) for generating Liquid Glass icons and asset catalogs (macOS) (#4934) by @wimaha ## Changed diff --git a/v3/internal/commands/build-assets.go b/v3/internal/commands/build-assets.go index b1f928407..3e30980b2 100644 --- a/v3/internal/commands/build-assets.go +++ b/v3/internal/commands/build-assets.go @@ -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) { diff --git a/v3/internal/commands/build-assets_test.go b/v3/internal/commands/build-assets_test.go index c100d6197..70ca018e5 100644 --- a/v3/internal/commands/build-assets_test.go +++ b/v3/internal/commands/build-assets_test.go @@ -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 diff --git a/v3/internal/commands/build_assets/Taskfile.tmpl.yml b/v3/internal/commands/build_assets/Taskfile.tmpl.yml index 82bc6868f..07592bff8 100644 --- a/v3/internal/commands/build_assets/Taskfile.tmpl.yml +++ b/v3/internal/commands/build_assets/Taskfile.tmpl.yml @@ -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 diff --git a/v3/internal/commands/build_assets/appicon.icon/Assets/wails_icon_vector.svg b/v3/internal/commands/build_assets/appicon.icon/Assets/wails_icon_vector.svg new file mode 100644 index 000000000..b099222f2 --- /dev/null +++ b/v3/internal/commands/build_assets/appicon.icon/Assets/wails_icon_vector.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/v3/internal/commands/build_assets/appicon.icon/icon.json b/v3/internal/commands/build_assets/appicon.icon/icon.json new file mode 100644 index 000000000..ecf18497c --- /dev/null +++ b/v3/internal/commands/build_assets/appicon.icon/icon.json @@ -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" + } +} \ No newline at end of file diff --git a/v3/internal/commands/build_assets/config.yml b/v3/internal/commands/build_assets/config.yml index 2912d16d6..03cbfa9dd 100644 --- a/v3/internal/commands/build_assets/config.yml +++ b/v3/internal/commands/build_assets/config.yml @@ -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. diff --git a/v3/internal/commands/build_assets/darwin/Assets.car b/v3/internal/commands/build_assets/darwin/Assets.car new file mode 100644 index 000000000..4def9c322 Binary files /dev/null and b/v3/internal/commands/build_assets/darwin/Assets.car differ diff --git a/v3/internal/commands/build_assets/darwin/Taskfile.yml b/v3/internal/commands/build_assets/darwin/Taskfile.yml index 50600ced9..041bd2091 100644 --- a/v3/internal/commands/build_assets/darwin/Taskfile.yml +++ b/v3/internal/commands/build_assets/darwin/Taskfile.yml @@ -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" diff --git a/v3/internal/commands/build_assets/darwin/icons.icns b/v3/internal/commands/build_assets/darwin/icons.icns index 1b5bd4c86..458ce1992 100644 Binary files a/v3/internal/commands/build_assets/darwin/icons.icns and b/v3/internal/commands/build_assets/darwin/icons.icns differ diff --git a/v3/internal/commands/icons.go b/v3/internal/commands/icons.go index cd852671e..a25482d74 100644 --- a/v3/internal/commands/icons.go +++ b/v3/internal/commands/icons.go @@ -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 diff --git a/v3/internal/commands/icons_test.go b/v3/internal/commands/icons_test.go index 58ae7d6e9..b13823e92 100644 --- a/v3/internal/commands/icons_test.go +++ b/v3/internal/commands/icons_test.go @@ -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 { diff --git a/v3/internal/commands/updatable_build_assets/darwin/Info.dev.plist.tmpl b/v3/internal/commands/updatable_build_assets/darwin/Info.dev.plist.tmpl index b68a8f1f7..151629739 100644 --- a/v3/internal/commands/updatable_build_assets/darwin/Info.dev.plist.tmpl +++ b/v3/internal/commands/updatable_build_assets/darwin/Info.dev.plist.tmpl @@ -17,6 +17,10 @@ {{.ProductVersion}} CFBundleIconFile icons + {{- if .CFBundleIconName}} + CFBundleIconName + {{.CFBundleIconName}} + {{- end}} LSMinimumSystemVersion 10.15.0 NSHighResolutionCapable diff --git a/v3/internal/commands/updatable_build_assets/darwin/Info.plist.tmpl b/v3/internal/commands/updatable_build_assets/darwin/Info.plist.tmpl index 2767128ff..5792cbf93 100644 --- a/v3/internal/commands/updatable_build_assets/darwin/Info.plist.tmpl +++ b/v3/internal/commands/updatable_build_assets/darwin/Info.plist.tmpl @@ -17,6 +17,10 @@ {{.ProductVersion}} CFBundleIconFile icons + {{- if .CFBundleIconName}} + CFBundleIconName + {{.CFBundleIconName}} + {{- end}} LSMinimumSystemVersion 10.15.0 NSHighResolutionCapable