mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 14:45:49 +01:00
* 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>
448 lines
15 KiB
Go
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)
|
|
}
|
|
}
|