Merge branch 'v3-alpha' into refactor/runtime-init-scripts

This commit is contained in:
Lea Anthony 2026-02-01 10:08:54 +11:00 committed by GitHub
commit c0862bf6d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 840 additions and 278 deletions

View file

@ -94,10 +94,18 @@ jobs:
build-args: |
ZIG_VERSION=${{ steps.vars.outputs.zig_version }}
MACOS_SDK_VERSION=${{ steps.vars.outputs.sdk_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: |
type=gha
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: |
type=gha,mode=max
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
# Test cross-compilation for all platforms
# Test cross-compilation using wails3 task system (3 parallel jobs)
# Runs on Linux/amd64 runner - tests actual cross-compilation only:
# - darwin: cross-platform (arm64)
# - windows: cross-platform (arm64)
# - linux: cross-architecture (arm64 from amd64 runner)
test-cross-compile:
needs: build
if: ${{ inputs.skip_tests != 'true' }}
@ -106,33 +114,24 @@ jobs:
fail-fast: false
matrix:
include:
# Darwin targets (Zig + macOS SDK) - no platform emulation needed
# Darwin arm64 (Intel Macs are EOL, skip amd64)
- os: darwin
arch: arm64
platform: ""
expected_file: "Mach-O 64-bit.*arm64"
- os: darwin
arch: amd64
platform: ""
expected_file: "Mach-O 64-bit.*x86_64"
# Linux targets (GCC) - need platform to match architecture
- os: linux
arch: amd64
platform: "linux/amd64"
expected_file: "ELF 64-bit LSB.*x86-64"
- os: linux
arch: arm64
platform: "linux/arm64"
expected_file: "ELF 64-bit LSB.*ARM aarch64"
# Windows targets (Zig + mingw) - no platform emulation needed
expected: "Mach-O 64-bit.*arm64"
# Windows - both archs
- os: windows
arch: amd64
platform: ""
expected_file: "PE32\\+ executable.*x86-64"
expected: "PE32.*x86-64"
- os: windows
arch: arm64
platform: ""
expected_file: "PE32\\+ executable.*Aarch64"
expected: "PE32.*Aarch64"
# Linux - both archs via Docker
- os: linux
arch: amd64
expected: "ELF 64-bit LSB.*x86-64"
- os: linux
arch: arm64
expected: "ELF 64-bit LSB.*ARM aarch64"
steps:
- name: Checkout
@ -140,8 +139,29 @@ jobs:
with:
ref: ${{ inputs.branch || github.ref }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Linux dev dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Install wails3 CLI
run: |
cd v3
go install ./cmd/wails3
- name: Set up QEMU
if: matrix.platform != ''
if: matrix.os == 'linux'
uses: docker/setup-qemu-action@v3
- name: Log in to Container Registry
@ -151,95 +171,74 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create test CGO project
- name: Pull and tag Docker image
run: |
mkdir -p test-project
cd test-project
# Pull both platform variants and tag them for the task system
# The task uses --platform flag which requires matching arch variant
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build.outputs.image_tag || 'latest' }}"
# Create a minimal CGO test program
cat > main.go << 'EOF'
package main
/*
#include <stdlib.h>
int add(int a, int b) {
return a + b;
}
*/
import "C"
import "fmt"
func main() {
result := C.add(1, 2)
fmt.Printf("CGO test: 1 + 2 = %d\n", result)
}
EOF
cat > go.mod << 'EOF'
module test-cgo
go 1.21
EOF
- name: Build ${{ matrix.os }}/${{ matrix.arch }} (CGO)
run: |
cd test-project
PLATFORM_FLAG=""
if [ -n "${{ matrix.platform }}" ]; then
PLATFORM_FLAG="--platform ${{ matrix.platform }}"
# For Linux arm64 test, we need the arm64 variant
if [ "${{ matrix.os }}" = "linux" ] && [ "${{ matrix.arch }}" = "arm64" ]; then
docker pull --platform linux/arm64 "$IMAGE"
docker tag "$IMAGE" wails-cross
else
docker pull "$IMAGE"
docker tag "$IMAGE" wails-cross
fi
docker run --rm $PLATFORM_FLAG \
-v "$(pwd):/app" \
-e APP_NAME="test-cgo" \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build.outputs.image_tag || 'latest' }} \
${{ matrix.os }} ${{ matrix.arch }}
- name: Verify binary format
- name: Generate wails3 test project
run: |
cd test-project/bin
cd /tmp
wails3 init -n test-wails-app -t vanilla
cd test-wails-app
# Update replace directive to use absolute path (wails3 init adds relative path)
sed -i 's|replace github.com/wailsapp/wails/v3 => .*|replace github.com/wailsapp/wails/v3 => ${{ github.workspace }}/v3|' go.mod
- name: Cross-compile ${{ matrix.os }}/${{ matrix.arch }} via task
run: |
cd /tmp/test-wails-app
# For Linux, always use docker build to test the Docker image
if [ "${{ matrix.os }}" = "linux" ]; then
wails3 task linux:build:docker ARCH=${{ matrix.arch }}
else
wails3 task ${{ matrix.os }}:build ARCH=${{ matrix.arch }}
fi
- name: Verify binary
run: |
cd /tmp/test-wails-app/bin
ls -la
# Find the built binary
if [ "${{ matrix.os }}" = "windows" ]; then
BINARY=$(ls test-cgo-${{ matrix.os }}-${{ matrix.arch }}.exe 2>/dev/null || ls *.exe | head -1)
BINARY="test-wails-app.exe"
else
BINARY=$(ls test-cgo-${{ matrix.os }}-${{ matrix.arch }} 2>/dev/null || ls test-cgo* | grep -v '.exe' | head -1)
BINARY="test-wails-app"
fi
echo "Binary: $BINARY"
echo "Checking: $BINARY"
FILE_OUTPUT=$(file "$BINARY")
echo "File output: $FILE_OUTPUT"
echo " $FILE_OUTPUT"
# Verify the binary format matches expected
if echo "$FILE_OUTPUT" | grep -qE "${{ matrix.expected_file }}"; then
echo "✅ Binary format verified: ${{ matrix.os }}/${{ matrix.arch }}"
if echo "$FILE_OUTPUT" | grep -qE "${{ matrix.expected }}"; then
echo " ✅ Cross-compilation verified: ${{ matrix.os }}/${{ matrix.arch }}"
else
echo "❌ Binary format mismatch!"
echo "Expected pattern: ${{ matrix.expected_file }}"
echo "Got: $FILE_OUTPUT"
echo " ❌ Format mismatch! Expected: ${{ matrix.expected }}"
exit 1
fi
- name: Check library dependencies (Linux only)
if: matrix.os == 'linux'
run: |
cd test-project/bin
BINARY=$(ls test-cgo-${{ matrix.os }}-${{ matrix.arch }} 2>/dev/null || ls test-cgo* | grep -v '.exe' | head -1)
cd /tmp/test-wails-app/bin
echo "## Library Dependencies for $BINARY"
echo "## Library Dependencies"
echo ""
# Use readelf to show dynamic dependencies
echo "### NEEDED libraries:"
readelf -d "$BINARY" | grep NEEDED || echo "No dynamic dependencies (statically linked)"
# Verify expected libraries are linked
readelf -d test-wails-app | grep NEEDED || echo "No dynamic dependencies"
echo ""
echo "### Verifying required libraries..."
NEEDED=$(readelf -d "$BINARY" | grep NEEDED)
NEEDED=$(readelf -d test-wails-app | grep NEEDED)
MISSING=""
for lib in libwebkit2gtk-4.1.so libgtk-3.so libglib-2.0.so libc.so; do
if echo "$NEEDED" | grep -q "$lib"; then
@ -251,139 +250,13 @@ jobs:
done
if [ -n "$MISSING" ]; then
echo ""
echo "ERROR: Missing required libraries:$MISSING"
exit 1
fi
# Test non-CGO builds (pure Go cross-compilation)
test-non-cgo:
needs: build
if: ${{ inputs.skip_tests != 'true' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- os: darwin
arch: arm64
expected_file: "Mach-O 64-bit.*arm64"
- os: darwin
arch: amd64
expected_file: "Mach-O 64-bit.*x86_64"
- os: linux
arch: amd64
expected_file: "ELF 64-bit LSB"
- os: linux
arch: arm64
expected_file: "ELF 64-bit LSB.*ARM aarch64"
- os: windows
arch: amd64
expected_file: "PE32\\+ executable.*x86-64"
- os: windows
arch: arm64
expected_file: "PE32\\+ executable.*Aarch64"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.branch || github.ref }}
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create test non-CGO project
run: |
mkdir -p test-project
cd test-project
# Create a pure Go test program (no CGO)
cat > main.go << 'EOF'
package main
import "fmt"
func main() {
fmt.Println("Pure Go cross-compilation test")
}
EOF
cat > go.mod << 'EOF'
module test-pure-go
go 1.21
EOF
- name: Build ${{ matrix.os }}/${{ matrix.arch }} (non-CGO)
run: |
cd test-project
# For non-CGO, we can use any platform since Go handles cross-compilation
# We set CGO_ENABLED=0 to ensure pure Go build
docker run --rm \
-v "$(pwd):/app" \
-e APP_NAME="test-pure-go" \
-e CGO_ENABLED=0 \
--entrypoint /bin/sh \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build.outputs.image_tag || 'latest' }} \
-c "GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build -o bin/test-pure-go-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} ."
- name: Verify binary format
run: |
cd test-project/bin
ls -la
# Find the built binary
if [ "${{ matrix.os }}" = "windows" ]; then
BINARY="test-pure-go-${{ matrix.os }}-${{ matrix.arch }}.exe"
else
BINARY="test-pure-go-${{ matrix.os }}-${{ matrix.arch }}"
fi
echo "Binary: $BINARY"
FILE_OUTPUT=$(file "$BINARY")
echo "File output: $FILE_OUTPUT"
# Verify the binary format matches expected
if echo "$FILE_OUTPUT" | grep -qE "${{ matrix.expected_file }}"; then
echo "✅ Binary format verified: ${{ matrix.os }}/${{ matrix.arch }} (non-CGO)"
else
echo "❌ Binary format mismatch!"
echo "Expected pattern: ${{ matrix.expected_file }}"
echo "Got: $FILE_OUTPUT"
exit 1
fi
- name: Check library dependencies (Linux only)
if: matrix.os == 'linux'
run: |
cd test-project/bin
BINARY="test-pure-go-${{ matrix.os }}-${{ matrix.arch }}"
echo "## Library Dependencies for $BINARY (non-CGO)"
echo ""
# Non-CGO builds should have minimal dependencies (just libc or statically linked)
echo "### NEEDED libraries:"
readelf -d "$BINARY" | grep NEEDED || echo "No dynamic dependencies (statically linked)"
# Verify NO GTK/WebKit libraries (since CGO is disabled)
NEEDED=$(readelf -d "$BINARY" | grep NEEDED || true)
if echo "$NEEDED" | grep -q "libwebkit\|libgtk"; then
echo "❌ ERROR: Non-CGO binary should not link to GTK/WebKit!"
exit 1
else
echo "✅ Confirmed: No GTK/WebKit dependencies (expected for non-CGO)"
fi
# Summary job
test-summary:
needs: [build, test-cross-compile, test-non-cgo]
needs: [build, test-cross-compile]
if: always() && inputs.skip_tests != 'true'
runs-on: ubuntu-latest
steps:
@ -393,30 +266,23 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ needs.test-cross-compile.result }}" = "success" ]; then
echo "✅ **CGO Tests**: All passed" >> $GITHUB_STEP_SUMMARY
echo "✅ **All Tests Passed**" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **CGO Tests**: Failed" >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ needs.test-non-cgo.result }}" = "success" ]; then
echo "✅ **Non-CGO Tests**: All passed" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Non-CGO Tests**: Failed" >> $GITHUB_STEP_SUMMARY
echo "❌ **Some Tests Failed**" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Tested Platforms" >> $GITHUB_STEP_SUMMARY
echo "| Platform | Architecture | CGO | Non-CGO |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------------|-----|---------|" >> $GITHUB_STEP_SUMMARY
echo "| Darwin | arm64 | ✅ | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Darwin | amd64 | ✅ | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Linux | arm64 | ✅ | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Linux | amd64 | ✅ | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Windows | arm64 | ✅ | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Windows | amd64 | ✅ | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "### Cross-Compilation Tests (from Linux/amd64 runner)" >> $GITHUB_STEP_SUMMARY
echo "| Target | Status |" >> $GITHUB_STEP_SUMMARY
echo "|--------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Darwin/arm64 | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Windows/amd64 | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Windows/arm64 | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Linux/amd64 | ✅ |" >> $GITHUB_STEP_SUMMARY
echo "| Linux/arm64 | ✅ |" >> $GITHUB_STEP_SUMMARY
# Fail if any test failed
if [ "${{ needs.test-cross-compile.result }}" != "success" ] || [ "${{ needs.test-non-cgo.result }}" != "success" ]; then
if [ "${{ needs.test-cross-compile.result }}" != "success" ]; then
echo ""
echo "❌ Some tests failed. Check the individual job logs for details."
exit 1

View file

@ -0,0 +1,143 @@
name: Cross-Compile Test v3
on:
pull_request_review:
types: [submitted]
branches:
- v3-alpha
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to test (optional, uses current branch if not specified)'
required: false
type: string
jobs:
check_approval:
name: Check PR Approval
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event.review.state == 'approved'
outputs:
approved: ${{ steps.check.outputs.approved }}
steps:
- name: Check if PR is approved or manual dispatch
id: check
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "Manual dispatch, proceeding with cross-compile tests"
else
echo "PR approved, proceeding with cross-compile tests"
fi
echo "approved=true" >> $GITHUB_OUTPUT
cross_compile:
name: Cross-Compile (${{ matrix.target_os }}/${{ matrix.target_arch }})
needs: check_approval
runs-on: ubuntu-latest
if: needs.check_approval.outputs.approved == 'true'
strategy:
fail-fast: false
matrix:
include:
- target_os: darwin
target_arch: arm64
- target_os: darwin
target_arch: amd64
- target_os: linux
target_arch: arm64
- target_os: linux
target_arch: amd64
- target_os: windows
target_arch: arm64
- target_os: windows
target_arch: amd64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout PR (if specified)
if: github.event_name == 'workflow_dispatch' && inputs.pr_number != ''
run: gh pr checkout ${{ inputs.pr_number }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
cache-dependency-path: "v3/go.sum"
- name: Install Task
uses: arduino/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Linux dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: libgtk-3-dev libwebkit2gtk-4.1-dev build-essential pkg-config
version: 1.0
- name: Install Wails3 CLI
working-directory: v3
run: |
go install ./cmd/wails3
wails3 version
- name: Create test project
run: |
mkdir -p test-cross-compile
cd test-cross-compile
wails3 init -n crosstest -t vanilla
- name: Set up QEMU for cross-platform emulation
if: matrix.target_os == 'linux' && matrix.target_arch != 'amd64'
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Docker cross-compile image
working-directory: test-cross-compile/crosstest
run: |
# For Linux cross-arch builds, build the Docker image for the target platform
if [[ "${{ matrix.target_os }}" == "linux" && "${{ matrix.target_arch }}" != "amd64" ]]; then
echo "Building Docker image for linux/${{ matrix.target_arch }}..."
docker buildx build --platform linux/${{ matrix.target_arch }} --load -t wails-cross -f build/docker/Dockerfile.cross build/docker/
else
task common:setup:docker
fi
- name: Fix replace directive for Docker build
working-directory: test-cross-compile/crosstest
run: |
# Change the replace directive to use absolute path that matches Docker mount
go mod edit -dropreplace github.com/wailsapp/wails/v3
go mod edit -replace github.com/wailsapp/wails/v3=${{ github.workspace }}/v3
- name: Cross-compile for ${{ matrix.target_os }}/${{ matrix.target_arch }}
working-directory: test-cross-compile/crosstest
run: |
echo "Cross-compiling for ${{ matrix.target_os }}/${{ matrix.target_arch }}..."
task ${{ matrix.target_os }}:build ARCH=${{ matrix.target_arch }}
echo "Cross-compilation successful!"
ls -la bin/
cross_compile_results:
if: ${{ always() }}
runs-on: ubuntu-latest
name: Cross-Compile Results
needs: [cross_compile]
steps:
- run: |
result="${{ needs.cross_compile.result }}"
echo "Cross-compile result: $result"
if [[ $result == "success" ]]; then
echo "All cross-compile tests passed!"
exit 0
else
echo "One or more cross-compile tests failed"
exit 1
fi

View file

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

View file

@ -115,7 +115,7 @@ In this tutorial, you'll build a notes application that demonstrates file operat
// SaveToFile saves notes to a file
func (n *NotesService) SaveToFile() error {
path, err := application.SaveFileDialog().
path, err := application.Get().Dialog.SaveFile().
SetFilename("notes.json").
AddFilter("JSON Files", "*.json").
PromptForSingleSelection()
@ -133,7 +133,7 @@ In this tutorial, you'll build a notes application that demonstrates file operat
return err
}
application.InfoDialog().
application.Get().Dialog.Info().
SetTitle("Success").
SetMessage("Notes saved successfully!").
Show()
@ -143,7 +143,7 @@ In this tutorial, you'll build a notes application that demonstrates file operat
// LoadFromFile loads notes from a file
func (n *NotesService) LoadFromFile() error {
path, err := application.OpenFileDialog().
path, err := application.Get().Dialog.OpenFile().
AddFilter("JSON Files", "*.json").
PromptForSingleSelection()
@ -163,7 +163,7 @@ In this tutorial, you'll build a notes application that demonstrates file operat
n.notes = notes
application.InfoDialog().
application.Get().Dialog.Info().
SetTitle("Success").
SetMessage("Notes loaded successfully!").
Show()
@ -180,8 +180,8 @@ In this tutorial, you'll build a notes application that demonstrates file operat
- **Note struct**: Defines the data structure with JSON tags (lowercase) for proper serialization
- **CRUD operations**: GetAll, Create, Update, and Delete for managing notes in memory
- **File dialogs**: Uses package-level functions `application.SaveFileDialog()` and `application.OpenFileDialog()`
- **Info dialogs**: Shows success messages using `application.InfoDialog()`
- **File dialogs**: Uses `application.Get().Dialog.SaveFile()` and `application.Get().Dialog.OpenFile()` to access native dialogs
- **Info dialogs**: Shows success messages using `application.Get().Dialog.Info()`
- **ID generation**: Simple timestamp-based ID generator
3. **Update main.go**

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 698 B

View file

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

View file

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

Binary file not shown.

View file

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

View file

@ -1,7 +1,7 @@
# Cross-compile Wails v3 apps to any platform
#
# Darwin: Zig + macOS SDK
# Linux: GCC (use --platform to match target arch)
# Linux: Native GCC when host matches target, Zig for cross-arch
# Windows: Zig + bundled mingw
#
# Usage:
@ -12,9 +12,6 @@
# docker run --rm -v $(pwd):/app wails-cross linux arm64
# docker run --rm -v $(pwd):/app wails-cross windows amd64
# docker run --rm -v $(pwd):/app wails-cross windows arm64
#
# Multi-arch build:
# docker buildx build --platform linux/amd64,linux/arm64 -t wails-cross -f Dockerfile.cross .
FROM golang:1.25-bookworm
@ -27,9 +24,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgtk-4-dev libwebkitgtk-6.0-dev \
&& rm -rf /var/lib/apt/lists/*
# Note: For Linux cross-compilation, use --platform to match target architecture
# e.g., docker run --platform linux/amd64 ... for amd64 targets
# Install Zig - automatically selects correct binary for host architecture
ARG ZIG_VERSION=0.14.0
RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
@ -45,8 +39,8 @@ RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_
ENV MACOS_SDK_PATH=/opt/macos-sdk
# Create Zig CC wrappers for Darwin and Windows targets
# (Linux uses native GCC with --platform flag for architecture matching)
# Create Zig CC wrappers for cross-compilation targets
# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
# Darwin arm64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64

View file

@ -17,8 +17,11 @@ tasks:
build:
summary: Builds the application for Linux
cmds:
# Linux requires CGO - use Docker when cross-compiling from non-Linux OR when no C compiler is available
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true")}}build:native{{else}}build:docker{{end}}'
# Linux requires CGO - use Docker when:
# 1. Cross-compiling from non-Linux, OR
# 2. No C compiler is available, OR
# 3. Target architecture differs from host architecture (cross-arch compilation)
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
@ -26,6 +29,8 @@ tasks:
vars:
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Determine target architecture (defaults to host ARCH if not specified)
TARGET_ARCH: '{{.ARCH | default ARCH}}'
# Check if a C compiler is available (gcc or clang)
HAS_CC:
sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
@ -55,7 +60,7 @@ tasks:
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: Cross-compiles for Linux using Docker with Zig (for macOS/Windows hosts)
summary: Cross-compiles for Linux using Docker (uses QEMU for cross-arch builds)
internal: true
deps:
- task: common:build:frontend
@ -69,6 +74,7 @@ tasks:
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
# Use --platform to enable QEMU emulation when target arch differs from host
- docker run --rm --platform linux/{{.DOCKER_ARCH}} -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
- mkdir -p {{.BIN_DIR}}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Build Linux binaries for Wails v3 examples using Ubuntu 24.04 (ARM64 native)
FROM ubuntu:24.04

View file

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Build Linux binaries for Wails v3 examples using Ubuntu 24.04 (x86_64 native)
FROM --platform=linux/amd64 ubuntu:24.04