wails/v3/internal/commands/build-assets.go
Wilko 2db6a1c427
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>
2026-02-01 10:07:56 +11:00

448 lines
15 KiB
Go

package commands
import (
"embed"
_ "embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/leaanthony/gosod"
"gopkg.in/yaml.v3"
"howett.net/plist"
)
//go:embed build_assets
var buildAssets embed.FS
//go:embed updatable_build_assets
var updatableBuildAssets embed.FS
// ProtocolConfig defines the structure for a custom protocol in wails.json/wails.yaml
type ProtocolConfig struct {
Scheme string `yaml:"scheme" json:"scheme"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
// Future platform-specific fields can be added here if needed by templates.
// E.g., for macOS: CFBundleURLName string `yaml:"cfBundleURLName,omitempty" json:"cfBundleURLName,omitempty"`
}
// BuildAssetsOptions defines the options for generating build assets.
type BuildAssetsOptions struct {
Dir string `description:"The directory to generate the files into" default:"."`
Name string `description:"The name of the project"`
BinaryName string `description:"The name of the binary"`
ProductName string `description:"The name of the product" default:"My Product"`
ProductDescription string `description:"The description of the product" default:"My Product Description"`
ProductVersion string `description:"The version of the product" default:"0.1.0"`
ProductCompany string `description:"The company of the product" default:"My Company"`
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"`
ExecutableName string `description:"Name of executable for MSIX package"`
OutputPath string `description:"Output path for MSIX package"`
CertificatePath string `description:"Certificate path for MSIX package"`
Silent bool `description:"Suppress output to console"`
Typescript bool `description:"Use typescript" default:"false"`
}
// BuildConfig defines the configuration for generating build assets.
type BuildConfig struct {
BuildAssetsOptions
FileAssociations []FileAssociation `yaml:"fileAssociations"`
Protocols []ProtocolConfig `yaml:"protocols,omitempty"`
}
// UpdateBuildAssetsOptions defines the options for updating build assets.
type UpdateBuildAssetsOptions struct {
Dir string `description:"The directory to generate the files into" default:"build"`
Name string `description:"The name of the project"`
BinaryName string `description:"The name of the binary"`
ProductName string `description:"The name of the product" default:"My Product"`
ProductDescription string `description:"The description of the product" default:"My Product Description"`
ProductVersion string `description:"The version of the product" default:"0.1.0"`
ProductCompany string `description:"The company of the product" default:"My Company"`
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"`
}
// GenerateBuildAssets generates the build assets for the project.
func GenerateBuildAssets(options *BuildAssetsOptions) error {
DisableFooter = true
var err error
options.Dir, err = filepath.Abs(options.Dir)
if err != nil {
return err
}
// If directory doesn't exist, create it
if _, err := os.Stat(options.Dir); os.IsNotExist(err) {
err = os.MkdirAll(options.Dir, 0755)
if err != nil {
return err
}
}
var config BuildConfig
if options.ProductComments == "" {
options.ProductComments = fmt.Sprintf("(c) %d %s", time.Now().Year(), options.ProductCompany)
}
if options.ProductIdentifier == "" {
options.ProductIdentifier = "com.wails." + normaliseName(options.Name)
}
if options.BinaryName == "" {
options.BinaryName = normaliseName(options.Name)
if runtime.GOOS == "windows" {
options.BinaryName += ".exe"
}
}
if options.Publisher == "" {
options.Publisher = fmt.Sprintf("CN=%s", options.ProductCompany)
}
if options.ProcessorArchitecture == "" {
options.ProcessorArchitecture = "x64"
}
if options.ExecutableName == "" {
options.ExecutableName = options.BinaryName
}
if options.ExecutablePath == "" {
options.ExecutablePath = options.BinaryName
}
if options.OutputPath == "" {
options.OutputPath = fmt.Sprintf("%s.msix", normaliseName(options.Name))
}
// CertificatePath is optional, no default needed
config.BuildAssetsOptions = *options
tfs, err := fs.Sub(buildAssets, "build_assets")
if err != nil {
return err
}
if !options.Silent {
println("Generating build assets in " + options.Dir)
}
err = gosod.New(tfs).Extract(options.Dir, config)
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
}
return nil
}
// FileAssociation defines the structure for a file association.
type FileAssociation struct {
Ext string `yaml:"ext"`
Name string `yaml:"name"`
Description string `yaml:"description"`
IconName string `yaml:"iconName"`
Role string `yaml:"role"`
MimeType string `yaml:"mimeType"`
}
// UpdateConfig defines the configuration for updating build assets.
type UpdateConfig struct {
UpdateBuildAssetsOptions
FileAssociations []FileAssociation `yaml:"fileAssociations"`
Protocols []ProtocolConfig `yaml:"protocols,omitempty"`
}
// WailsConfig defines the structure for a Wails configuration.
type WailsConfig struct {
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"`
} `yaml:"info"`
FileAssociations []FileAssociation `yaml:"fileAssociations,omitempty"`
Protocols []ProtocolConfig `yaml:"protocols,omitempty"`
}
// UpdateBuildAssets updates the build assets for the project.
func UpdateBuildAssets(options *UpdateBuildAssetsOptions) error {
DisableFooter = true
var err error
options.Dir, err = filepath.Abs(options.Dir)
if err != nil {
return err
}
var config UpdateConfig
if options.Config != "" {
var wailsConfig WailsConfig
bytes, err := os.ReadFile(options.Config)
if err != nil {
return err
}
err = yaml.Unmarshal(bytes, &wailsConfig)
if err != nil {
return err
}
if options.ProductCompany == "My Company" && wailsConfig.Info.CompanyName != "" {
options.ProductCompany = wailsConfig.Info.CompanyName
}
if options.ProductName == "My Product" && wailsConfig.Info.ProductName != "" {
options.ProductName = wailsConfig.Info.ProductName
}
if options.ProductIdentifier == "" {
options.ProductIdentifier = wailsConfig.Info.ProductIdentifier
}
if options.ProductDescription == "My Product Description" && wailsConfig.Info.Description != "" {
options.ProductDescription = wailsConfig.Info.Description
}
if options.ProductCopyright == "© now, My Company" && wailsConfig.Info.Copyright != "" {
options.ProductCopyright = wailsConfig.Info.Copyright
}
if options.ProductComments == "This is a comment" && wailsConfig.Info.Comments != "" {
options.ProductComments = wailsConfig.Info.Comments
}
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
}
config.UpdateBuildAssetsOptions = *options
// If directory doesn't exist, create it
if _, err := os.Stat(options.Dir); os.IsNotExist(err) {
err = os.MkdirAll(options.Dir, 0755)
if err != nil {
return err
}
}
// 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
}
// Backup existing plist files before extraction
backups, err := backupPlistFiles(options.Dir)
if err != nil {
return err
}
// Extract new assets (overwrites existing files)
err = gosod.New(tfs).Extract(options.Dir, config)
if err != nil {
return err
}
// Merge backed-up content into newly extracted plists
err = mergeBackupPlists(backups)
if err != nil {
return err
}
// Clean up backup files
cleanupBackups(backups)
if !options.Silent {
println("Successfully updated build assets in " + options.Dir)
}
return nil
}
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) {
for key, srcValue := range src {
if dstValue, exists := dst[key]; exists {
// If both are maps, merge recursively
srcMap, srcIsMap := srcValue.(map[string]any)
dstMap, dstIsMap := dstValue.(map[string]any)
if srcIsMap && dstIsMap {
mergeMaps(dstMap, srcMap)
continue
}
}
// Otherwise, src overwrites dst
dst[key] = srcValue
}
}
// plistBackup holds the original path and backup path for a plist file
type plistBackup struct {
originalPath string
backupPath string
}
// backupPlistFiles finds all .plist files in dir and renames them to .plist.bak
func backupPlistFiles(dir string) ([]plistBackup, error) {
var backups []plistBackup
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || !strings.HasSuffix(path, ".plist") {
return nil
}
backupPath := path + ".bak"
if err := os.Rename(path, backupPath); err != nil {
return fmt.Errorf("failed to backup plist %s: %w", path, err)
}
backups = append(backups, plistBackup{originalPath: path, backupPath: backupPath})
return nil
})
return backups, err
}
// mergeBackupPlists merges the backed-up plist content into the newly extracted plists
func mergeBackupPlists(backups []plistBackup) error {
for _, backup := range backups {
// Read the backup (original user content)
backupContent, err := os.ReadFile(backup.backupPath)
if err != nil {
return fmt.Errorf("failed to read backup %s: %w", backup.backupPath, err)
}
var backupDict map[string]any
if _, err := plist.Unmarshal(backupContent, &backupDict); err != nil {
return fmt.Errorf("failed to parse backup plist %s: %w", backup.backupPath, err)
}
// Read the newly extracted plist
newContent, err := os.ReadFile(backup.originalPath)
if err != nil {
// New file might not exist if template didn't generate one for this path
continue
}
var newDict map[string]any
if _, err := plist.Unmarshal(newContent, &newDict); err != nil {
return fmt.Errorf("failed to parse new plist %s: %w", backup.originalPath, err)
}
// Merge: start with backup (user's content), apply new values on top
mergeMaps(backupDict, newDict)
// Write merged result
file, err := os.Create(backup.originalPath)
if err != nil {
return fmt.Errorf("failed to create merged plist %s: %w", backup.originalPath, err)
}
encoder := plist.NewEncoder(file)
encoder.Indent("\t")
if err := encoder.Encode(backupDict); err != nil {
file.Close()
return fmt.Errorf("failed to encode merged plist %s: %w", backup.originalPath, err)
}
file.Close()
}
return nil
}
// cleanupBackups removes the backup files after successful merge
func cleanupBackups(backups []plistBackup) {
for _, backup := range backups {
os.Remove(backup.backupPath)
}
}