mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 14:45:49 +01:00
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:
parent
7e74a5d9d0
commit
2db6a1c427
15 changed files with 580 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
51
v3/internal/commands/build_assets/appicon.icon/icon.json
Normal file
51
v3/internal/commands/build_assets/appicon.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
BIN
v3/internal/commands/build_assets/darwin/Assets.car
Normal file
BIN
v3/internal/commands/build_assets/darwin/Assets.car
Normal file
Binary file not shown.
|
|
@ -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"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue