diff --git a/.github/workflows/build-cross-image.yml b/.github/workflows/build-cross-image.yml index 3075956fe..004831139 100644 --- a/.github/workflows/build-cross-image.yml +++ b/.github/workflows/build-cross-image.yml @@ -94,8 +94,12 @@ 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 using wails3 task system (3 parallel jobs) # Runs on Linux/amd64 runner - tests actual cross-compilation only: @@ -121,7 +125,10 @@ jobs: - os: windows arch: arm64 expected: "PE32.*Aarch64" - # Linux arm64 (amd64 would be native on runner) + # 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" @@ -191,9 +198,9 @@ jobs: - name: Cross-compile ${{ matrix.os }}/${{ matrix.arch }} via task run: | cd /tmp/test-wails-app - # For Linux arm64 on amd64 runner, must use docker build (native gcc can't cross-compile) - if [ "${{ matrix.os }}" = "linux" ] && [ "${{ matrix.arch }}" = "arm64" ]; then - wails3 task linux:build:docker ARCH=arm64 + # 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 @@ -271,6 +278,7 @@ jobs: 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 diff --git a/.github/workflows/cross-compile-test-v3.yml b/.github/workflows/cross-compile-test-v3.yml new file mode 100644 index 000000000..07996d2d3 --- /dev/null +++ b/.github/workflows/cross-compile-test-v3.yml @@ -0,0 +1,135 @@ +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: ${{ matrix.runner }} + if: needs.check_approval.outputs.approved == 'true' + strategy: + fail-fast: false + matrix: + include: + - target_os: darwin + target_arch: arm64 + runner: ubuntu-latest + - target_os: darwin + target_arch: amd64 + runner: ubuntu-latest + - target_os: linux + target_arch: arm64 + runner: ubuntu-24.04-arm # Native ARM64 runner - much faster than QEMU + - target_os: linux + target_arch: amd64 + runner: ubuntu-latest + - target_os: windows + target_arch: arm64 + runner: ubuntu-latest + - target_os: windows + target_arch: amd64 + runner: ubuntu-latest + 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: Setup Docker cross-compile image + working-directory: test-cross-compile/crosstest + run: task common:setup:docker + + - 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 diff --git a/docs/src/content/docs/guides/build/macos.mdx b/docs/src/content/docs/guides/build/macos.mdx index 1d820daef..3271b2faa 100644 --- a/docs/src/content/docs/guides/build/macos.mdx +++ b/docs/src/content/docs/guides/build/macos.mdx @@ -17,7 +17,7 @@ wails3 package GOOS=darwin This creates `bin/.app` containing: - The compiled binary in `Contents/MacOS/` -- App icon in `Contents/Resources/` +- App icon in `Contents/Resources/` (from `icons.icns` or, when present, from an asset catalog `Assets.car`) - `Info.plist` with app metadata ### Universal Binary @@ -40,10 +40,23 @@ Edit `build/darwin/Info.plist` to customize: - File associations - URL schemes -The app icon is generated from `build/appicon.png`. Regenerate with: +The app icon is generated from assets in the `build/` directory. Use the `generate:icons` task: ```bash -wails3 generate icons -input build/appicon.png +wails3 task generate:icons +``` + +This uses `build/appicon.png` to produce `darwin/icons.icns` and `windows/icon.ico`. On macOS you can also provide `build/appicon.icon` (Icon Composer format): the task passes `-iconcomposerinput appicon.icon -macassetdir darwin`, which produces `Assets.car` and `darwin/icons.icns` from the `.icon` file (skipped on non-macOS platforms). When `Assets.car` is present, run the `update:build-assets` task so that `Info.plist` and `CFBundleIconName` are updated accordingly: + +```bash +wails3 task update:build-assets +``` + +To run the icon command manually from the `build/` directory: + +```bash +cd build +wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin ``` ## Code Signing diff --git a/docs/src/content/docs/tutorials/03-notes-vanilla.mdx b/docs/src/content/docs/tutorials/03-notes-vanilla.mdx index 329827c3a..bcdddf930 100644 --- a/docs/src/content/docs/tutorials/03-notes-vanilla.mdx +++ b/docs/src/content/docs/tutorials/03-notes-vanilla.mdx @@ -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** diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 8e4648038..778f59b06 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -16,7 +16,7 @@ After processing, the content will be moved to the main changelog and this file --> ## Added - +- Add support for using `.icon` files (Apple Icon Composer format) for generating Liquid Glass icons and asset catalogs (macOS) (#4934) by @wimaha ## Changed diff --git a/v3/internal/commands/build-assets.go b/v3/internal/commands/build-assets.go index b1f928407..3e30980b2 100644 --- a/v3/internal/commands/build-assets.go +++ b/v3/internal/commands/build-assets.go @@ -42,6 +42,7 @@ type BuildAssetsOptions struct { ProductCopyright string `description:"The copyright notice" default:"\u00a9 now, My Company"` ProductComments string `description:"Comments to add to the generated files" default:"This is a comment"` ProductIdentifier string `description:"The product identifier, e.g com.mycompany.myproduct"` + CFBundleIconName string `description:"The macOS icon name (for Assets.car icon bundles)"` Publisher string `description:"Publisher name for MSIX package (e.g., CN=CompanyName)"` ProcessorArchitecture string `description:"Processor architecture for MSIX package" default:"x64"` ExecutablePath string `description:"Path to executable for MSIX package"` @@ -71,6 +72,7 @@ type UpdateBuildAssetsOptions struct { ProductCopyright string `description:"The copyright notice" default:"© now, My Company"` ProductComments string `description:"Comments to add to the generated files" default:"This is a comment"` ProductIdentifier string `description:"The product identifier, e.g com.mycompany.myproduct"` + CFBundleIconName string `description:"The macOS icon name (for Assets.car icon bundles)"` Config string `description:"The path to the config file"` Silent bool `description:"Suppress output to console"` } @@ -146,10 +148,17 @@ func GenerateBuildAssets(options *BuildAssetsOptions) error { if err != nil { return err } + // Check if Assets.car exists - if so, set CFBundleIconName if not already set + // This must happen BEFORE the updatable_build_assets extraction so CFBundleIconName is available in Info.plist templates + checkAndSetCFBundleIconNameCommon(options.Dir, &buildCFBundleIconNameSetter{options, &config}) + // Update config with the potentially modified options + config.BuildAssetsOptions = *options + tfs, err = fs.Sub(updatableBuildAssets, "updatable_build_assets") if err != nil { return err } + err = gosod.New(tfs).Extract(options.Dir, config) if err != nil { return err @@ -185,6 +194,7 @@ type WailsConfig struct { Copyright string `yaml:"copyright"` Comments string `yaml:"comments"` Version string `yaml:"version"` + CFBundleIconName string `yaml:"cfBundleIconName,omitempty"` } `yaml:"info"` FileAssociations []FileAssociation `yaml:"fileAssociations,omitempty"` Protocols []ProtocolConfig `yaml:"protocols,omitempty"` @@ -233,6 +243,9 @@ func UpdateBuildAssets(options *UpdateBuildAssetsOptions) error { if options.ProductVersion == "0.1.0" && wailsConfig.Info.Version != "" { options.ProductVersion = wailsConfig.Info.Version } + if options.CFBundleIconName == "" && wailsConfig.Info.CFBundleIconName != "" { + options.CFBundleIconName = wailsConfig.Info.CFBundleIconName + } config.FileAssociations = wailsConfig.FileAssociations config.Protocols = wailsConfig.Protocols } @@ -247,6 +260,11 @@ func UpdateBuildAssets(options *UpdateBuildAssetsOptions) error { } } + // Check if Assets.car exists - if so, set CFBundleIconName if not already set + checkAndSetCFBundleIconNameCommon(options.Dir, &updateCFBundleIconNameSetter{options, &config}) + // Update config with the potentially modified options + config.UpdateBuildAssetsOptions = *options + tfs, err := fs.Sub(updatableBuildAssets, "updatable_build_assets") if err != nil { return err @@ -284,6 +302,51 @@ func normaliseName(name string) string { return strings.ToLower(strings.ReplaceAll(name, " ", "-")) } +// CFBundleIconNameSetter is implemented by types that can get and set CFBundleIconName +// (used to keep options and config in sync when defaulting the macOS icon name). +type CFBundleIconNameSetter interface { + GetCFBundleIconName() string + SetCFBundleIconName(string) +} + +// checkAndSetCFBundleIconNameCommon checks if Assets.car exists in the darwin folder +// and sets CFBundleIconName via setter if not already set. The icon name should be configured +// in config.yml under info.cfBundleIconName and should match the name of the .icon file without the extension +// with which Assets.car was generated. If not set, defaults to "appicon". +func checkAndSetCFBundleIconNameCommon(dir string, setter CFBundleIconNameSetter) { + darwinDir := filepath.Join(dir, "darwin") + assetsCarPath := filepath.Join(darwinDir, "Assets.car") + if _, err := os.Stat(assetsCarPath); err == nil { + if setter.GetCFBundleIconName() == "" { + setter.SetCFBundleIconName("appicon") + } + } +} + +// buildCFBundleIconNameSetter sets CFBundleIconName on both options and config for GenerateBuildAssets. +type buildCFBundleIconNameSetter struct { + options *BuildAssetsOptions + config *BuildConfig +} + +func (s *buildCFBundleIconNameSetter) GetCFBundleIconName() string { return s.options.CFBundleIconName } +func (s *buildCFBundleIconNameSetter) SetCFBundleIconName(v string) { + s.options.CFBundleIconName = v + s.config.CFBundleIconName = v +} + +// updateCFBundleIconNameSetter sets CFBundleIconName on both options and config for UpdateBuildAssets. +type updateCFBundleIconNameSetter struct { + options *UpdateBuildAssetsOptions + config *UpdateConfig +} + +func (s *updateCFBundleIconNameSetter) GetCFBundleIconName() string { return s.options.CFBundleIconName } +func (s *updateCFBundleIconNameSetter) SetCFBundleIconName(v string) { + s.options.CFBundleIconName = v + s.config.CFBundleIconName = v +} + // mergeMaps recursively merges src into dst. // For nested maps, it merges recursively. For other types, src overwrites dst. func mergeMaps(dst, src map[string]any) { diff --git a/v3/internal/commands/build-assets_test.go b/v3/internal/commands/build-assets_test.go index c100d6197..70ca018e5 100644 --- a/v3/internal/commands/build-assets_test.go +++ b/v3/internal/commands/build-assets_test.go @@ -168,6 +168,7 @@ func TestUpdateBuildAssets(t *testing.T) { Copyright string `yaml:"copyright"` Comments string `yaml:"comments"` Version string `yaml:"version"` + CFBundleIconName string `yaml:"cfBundleIconName,omitempty"` }{ CompanyName: "Config Company", ProductName: "Config Product", @@ -351,6 +352,161 @@ func TestPlistMerge(t *testing.T) { } } +func TestCFBundleIconNameDetection(t *testing.T) { + tempDir, err := os.MkdirTemp("", "wails-icon-name-test-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + tests := []struct { + name string + createAssetsCar bool + configIconName string + expectedIconName string + expectIconNameInPlist bool + }{ + { + name: "Assets.car exists, no config - should default to appicon", + createAssetsCar: true, + configIconName: "", + expectedIconName: "appicon", + expectIconNameInPlist: true, + }, + { + name: "Assets.car exists, config set - should use config", + createAssetsCar: true, + configIconName: "custom-icon", + expectedIconName: "custom-icon", + expectIconNameInPlist: true, + }, + { + name: "No Assets.car, no config - should not set", + createAssetsCar: false, + configIconName: "", + expectedIconName: "", + expectIconNameInPlist: false, + }, + { + name: "No Assets.car, config set - should use config", + createAssetsCar: false, + configIconName: "config-icon", + expectedIconName: "config-icon", + expectIconNameInPlist: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buildDir := filepath.Join(tempDir, tt.name) + darwinDir := filepath.Join(buildDir, "darwin") + err := os.MkdirAll(darwinDir, 0755) + if err != nil { + t.Fatalf("Failed to create darwin directory: %v", err) + } + + // Create Assets.car BEFORE calling UpdateBuildAssets if needed + // The check happens before template extraction, so CFBundleIconName will be available in the template + if tt.createAssetsCar { + assetsCarPath := filepath.Join(darwinDir, "Assets.car") + err = os.WriteFile(assetsCarPath, []byte("fake assets.car content"), 0644) + if err != nil { + t.Fatalf("Failed to create Assets.car: %v", err) + } + } + + // Create config file if icon name is set + configFile := "" + if tt.configIconName != "" { + configDir := filepath.Join(tempDir, "config-"+tt.name) + err = os.MkdirAll(configDir, 0755) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + configFile = filepath.Join(configDir, "wails.yaml") + config := WailsConfig{ + Info: struct { + CompanyName string `yaml:"companyName"` + ProductName string `yaml:"productName"` + ProductIdentifier string `yaml:"productIdentifier"` + Description string `yaml:"description"` + Copyright string `yaml:"copyright"` + Comments string `yaml:"comments"` + Version string `yaml:"version"` + CFBundleIconName string `yaml:"cfBundleIconName,omitempty"` + }{ + CompanyName: "Test Company", + ProductName: "Test Product", + ProductIdentifier: "com.test.product", + CFBundleIconName: tt.configIconName, + }, + } + + configBytes, err := yaml.Marshal(config) + if err != nil { + t.Fatalf("Failed to marshal config: %v", err) + } + + err = os.WriteFile(configFile, configBytes, 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + } + + options := &UpdateBuildAssetsOptions{ + Dir: buildDir, + Name: "TestApp", + ProductName: "Test App", + ProductVersion: "1.0.0", + ProductCompany: "Test Company", + ProductIdentifier: "com.test.app", + CFBundleIconName: tt.configIconName, + Config: configFile, + Silent: true, + } + + err = UpdateBuildAssets(options) + if err != nil { + t.Fatalf("UpdateBuildAssets failed: %v", err) + } + + // Verify CFBundleIconName was set correctly in options + if options.CFBundleIconName != tt.expectedIconName { + t.Errorf("Expected CFBundleIconName to be '%s', got '%s'", tt.expectedIconName, options.CFBundleIconName) + } + + // Check Info.plist if it exists + infoPlistPath := filepath.Join(darwinDir, "Info.plist") + if _, err := os.Stat(infoPlistPath); err == nil { + plistContent, err := os.ReadFile(infoPlistPath) + if err != nil { + t.Fatalf("Failed to read Info.plist: %v", err) + } + + var plistDict map[string]any + _, err = plist.Unmarshal(plistContent, &plistDict) + if err != nil { + t.Fatalf("Failed to parse Info.plist: %v", err) + } + + iconName, exists := plistDict["CFBundleIconName"] + if tt.expectIconNameInPlist { + if !exists { + t.Errorf("Expected CFBundleIconName to be present in Info.plist") + } else if iconName != tt.expectedIconName { + t.Errorf("Expected CFBundleIconName in Info.plist to be '%s', got '%v'", tt.expectedIconName, iconName) + } + } else { + if exists { + t.Errorf("Expected CFBundleIconName to not be present in Info.plist, but found '%v'", iconName) + } + } + } + }) + } +} + func TestNestedPlistMerge(t *testing.T) { tests := []struct { name string diff --git a/v3/internal/commands/build_assets/Taskfile.tmpl.yml b/v3/internal/commands/build_assets/Taskfile.tmpl.yml index 82bc6868f..07592bff8 100644 --- a/v3/internal/commands/build_assets/Taskfile.tmpl.yml +++ b/v3/internal/commands/build_assets/Taskfile.tmpl.yml @@ -97,15 +97,16 @@ tasks: - wails3 generate bindings -f {{ "'{{.BUILD_FLAGS}}'" }} -clean=true {{- if .Typescript}} -ts{{end}} generate:icons: - summary: Generates Windows `.ico` and Mac `.icns` files from an image + summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms). dir: build sources: - "appicon.png" + - "appicon.icon" generates: - "darwin/icons.icns" - "windows/icon.ico" cmds: - - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico + - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin dev:frontend: summary: Runs the frontend in development mode diff --git a/v3/internal/commands/build_assets/appicon.icon/Assets/wails_icon_vector.svg b/v3/internal/commands/build_assets/appicon.icon/Assets/wails_icon_vector.svg new file mode 100644 index 000000000..b099222f2 --- /dev/null +++ b/v3/internal/commands/build_assets/appicon.icon/Assets/wails_icon_vector.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/v3/internal/commands/build_assets/appicon.icon/icon.json b/v3/internal/commands/build_assets/appicon.icon/icon.json new file mode 100644 index 000000000..ecf18497c --- /dev/null +++ b/v3/internal/commands/build_assets/appicon.icon/icon.json @@ -0,0 +1,51 @@ +{ + "fill" : { + "automatic-gradient" : "extended-gray:1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "fill-specializations" : [ + { + "appearance" : "dark", + "value" : { + "solid" : "srgb:0.92143,0.92145,0.92144,1.00000" + } + }, + { + "appearance" : "tinted", + "value" : { + "solid" : "srgb:0.83742,0.83744,0.83743,1.00000" + } + } + ], + "image-name" : "wails_icon_vector.svg", + "name" : "wails_icon_vector", + "position" : { + "scale" : 1.25, + "translation-in-points" : [ + 36.890625, + 4.96875 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/v3/internal/commands/build_assets/config.yml b/v3/internal/commands/build_assets/config.yml index 2912d16d6..03cbfa9dd 100644 --- a/v3/internal/commands/build_assets/config.yml +++ b/v3/internal/commands/build_assets/config.yml @@ -12,6 +12,9 @@ info: copyright: "(c) 2025, My Company" # Copyright text comments: "Some Product Comments" # Comments version: "0.0.1" # The application version + # cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional) + # # Should match the name of your .icon file without the extension + # # If not set and Assets.car exists, defaults to "appicon" # iOS build configuration (uncomment to customise iOS project generation) # Note: Keys under `ios` OVERRIDE values under `info` when set. diff --git a/v3/internal/commands/build_assets/darwin/Assets.car b/v3/internal/commands/build_assets/darwin/Assets.car new file mode 100644 index 000000000..4def9c322 Binary files /dev/null and b/v3/internal/commands/build_assets/darwin/Assets.car differ diff --git a/v3/internal/commands/build_assets/darwin/Taskfile.yml b/v3/internal/commands/build_assets/darwin/Taskfile.yml index 50600ced9..041bd2091 100644 --- a/v3/internal/commands/build_assets/darwin/Taskfile.yml +++ b/v3/internal/commands/build_assets/darwin/Taskfile.yml @@ -141,6 +141,10 @@ tasks: - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS" - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + - | + if [ -f build/darwin/Assets.car ]; then + cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + fi - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS" - cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents" - task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}' @@ -162,6 +166,10 @@ tasks: - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS" - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + - | + if [ -f build/darwin/Assets.car ]; then + cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + fi - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS" - cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist" - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app" diff --git a/v3/internal/commands/build_assets/darwin/icons.icns b/v3/internal/commands/build_assets/darwin/icons.icns index 1b5bd4c86..458ce1992 100644 Binary files a/v3/internal/commands/build_assets/darwin/icons.icns and b/v3/internal/commands/build_assets/darwin/icons.icns differ diff --git a/v3/internal/commands/build_assets/docker/Dockerfile.cross b/v3/internal/commands/build_assets/docker/Dockerfile.cross index 3b24529f9..ddae7aa03 100644 --- a/v3/internal/commands/build_assets/docker/Dockerfile.cross +++ b/v3/internal/commands/build_assets/docker/Dockerfile.cross @@ -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 diff --git a/v3/internal/commands/build_assets/linux/Taskfile.yml b/v3/internal/commands/build_assets/linux/Taskfile.yml index e6a3d22cc..12854847f 100644 --- a/v3/internal/commands/build_assets/linux/Taskfile.yml +++ b/v3/internal/commands/build_assets/linux/Taskfile.yml @@ -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: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available) internal: true deps: - task: common:build:frontend @@ -69,7 +74,7 @@ tasks: Docker image '{{.CROSS_IMAGE}}' not found. Build it first: wails3 task setup:docker cmds: - - 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" {{.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}} - mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}" diff --git a/v3/internal/commands/icons.go b/v3/internal/commands/icons.go index cd852671e..a25482d74 100644 --- a/v3/internal/commands/icons.go +++ b/v3/internal/commands/icons.go @@ -2,24 +2,36 @@ package commands import ( "bytes" + "errors" "fmt" "image" "image/color" "image/png" "os" + "os/exec" + "path/filepath" + "runtime" "strconv" "strings" "github.com/jackmordaunt/icns/v2" "github.com/leaanthony/winicon" + "github.com/wailsapp/wails/v3/internal/operatingsystem" + "howett.net/plist" ) +// ErrMacAssetNotSupported is returned by generateMacAsset when mac asset generation +// is not supported on the current platform (e.g., non-macOS systems). +var ErrMacAssetNotSupported = errors.New("mac asset generation is only supported on macOS") + type IconsOptions struct { - Example bool `description:"Generate example icon file (appicon.png) in the current directory"` - Input string `description:"The input image file"` - Sizes string `description:"The sizes to generate in .ico file (comma separated)" default:"256,128,64,48,32,16"` - WindowsFilename string `description:"The output filename for the Windows icon" default:"icon.ico"` - MacFilename string `description:"The output filename for the Mac icon bundle" default:"icons.icns"` + Example bool `description:"Generate example icon file (appicon.png) in the current directory"` + Input string `description:"The input image file"` + Sizes string `description:"The sizes to generate in .ico file (comma separated)" default:"256,128,64,48,32,16"` + WindowsFilename string `description:"The output filename for the Windows icon"` + MacFilename string `description:"The output filename for the Mac icon bundle"` + IconComposerInput string `description:"The input Icon Composer file (.icon)"` + MacAssetDir string `description:"The output directory for the Mac assets (Assets.car and icons.icns)"` } func GenerateIcons(options *IconsOptions) error { @@ -29,12 +41,16 @@ func GenerateIcons(options *IconsOptions) error { return generateExampleIcon() } - if options.Input == "" { - return fmt.Errorf("input is required") + if options.Input == "" && options.IconComposerInput == "" { + return fmt.Errorf("either input or icon composer input is required") } - if options.WindowsFilename == "" && options.MacFilename == "" { - return fmt.Errorf("at least one output filename is required") + if options.Input != "" && options.WindowsFilename == "" && options.MacFilename == "" { + return fmt.Errorf("either windows filename or mac filename is required") + } + + if options.IconComposerInput != "" && options.MacAssetDir == "" { + return fmt.Errorf("mac asset directory is required") } // Parse sizes @@ -46,23 +62,49 @@ func GenerateIcons(options *IconsOptions) error { return err } } - iconData, err := os.ReadFile(options.Input) - if err != nil { - return err - } - if options.WindowsFilename != "" { - err := generateWindowsIcon(iconData, sizes, options) - if err != nil { - return err + // Generate Icons from Icon Composer input + macIconsGenerated := false + if options.IconComposerInput != "" { + if options.MacAssetDir != "" { + err := generateMacAsset(options) + if err != nil { + if errors.Is(err, ErrMacAssetNotSupported) { + // No fallback: Icon Composer path requires macOS; return so callers see unsupported-platform failure + if options.Input == "" { + return fmt.Errorf("icon composer input requires macOS for mac asset generation: %w", err) + } + // Fallback to input-based generation will run below + } else { + return err + } + } else { + macIconsGenerated = true + } } } - if options.MacFilename != "" { - err := generateMacIcon(iconData, options) + // Generate Icons from input image + if options.Input != "" { + iconData, err := os.ReadFile(options.Input) if err != nil { return err } + + if options.WindowsFilename != "" { + err := generateWindowsIcon(iconData, sizes, options) + if err != nil { + return err + } + } + + // Generate Icons from input image if no Mac icons were generated from Icon Composer input + if options.MacFilename != "" && !macIconsGenerated { + err := generateMacIcon(iconData, options) + if err != nil { + return err + } + } } return nil @@ -116,6 +158,150 @@ func generateMacIcon(iconData []byte, options *IconsOptions) error { return icns.Encode(dest, srcImg) } +func generateMacAsset(options *IconsOptions) error { + //Check if running on darwin (macOS), because this will only run on a mac + if runtime.GOOS != "darwin" { + return ErrMacAssetNotSupported + } + // Get system info, because this will only run on macOS 26 or later + info, err := operatingsystem.Info() + if err != nil { + return ErrMacAssetNotSupported + } + majorStr, _, found := strings.Cut(info.Version, ".") + if !found { + return ErrMacAssetNotSupported + } + major, err := strconv.Atoi(majorStr) + if err != nil { + return ErrMacAssetNotSupported + } + if major < 26 { + return ErrMacAssetNotSupported + } + + cmd := exec.Command("/usr/bin/actool", "--version") + versionPlist, err := cmd.Output() + if err != nil { + return ErrMacAssetNotSupported + } + + // Parse the plist to extract short-bundle-version + var plistData map[string]any + if _, err := plist.Unmarshal(versionPlist, &plistData); err != nil { + return ErrMacAssetNotSupported + } + + // Navigate to com.apple.actool.version -> short-bundle-version + actoolVersion, ok := plistData["com.apple.actool.version"].(map[string]any) + if !ok { + return ErrMacAssetNotSupported + } + + shortVersion, ok := actoolVersion["short-bundle-version"].(string) + if !ok { + return ErrMacAssetNotSupported + } + + // Parse the major version number (e.g., "26.2" -> 26) + actoolMajorStr, _, _ := strings.Cut(shortVersion, ".") + actoolMajor, err := strconv.Atoi(actoolMajorStr) + if err != nil { + return ErrMacAssetNotSupported + } + + if actoolMajor < 26 { + return ErrMacAssetNotSupported + } + + // Convert paths to absolute paths (required for actool) + iconComposerPath, err := filepath.Abs(options.IconComposerInput) + if err != nil { + return fmt.Errorf("failed to get absolute path for icon composer input: %w", err) + } + macAssetDirPath, err := filepath.Abs(options.MacAssetDir) + if err != nil { + return fmt.Errorf("failed to get absolute path for mac asset directory: %w", err) + } + + // Get Filename from Icon Composer input without extension + iconComposerFilename := filepath.Base(iconComposerPath) + iconComposerFilename = strings.TrimSuffix(iconComposerFilename, filepath.Ext(iconComposerFilename)) + + cmd = exec.Command("/usr/bin/actool", iconComposerPath, + "--compile", macAssetDirPath, + "--notices", "--warnings", "--errors", + "--output-partial-info-plist", filepath.Join(macAssetDirPath, "temp.plist"), + "--app-icon", iconComposerFilename, + "--enable-on-demand-resources", "NO", + "--development-region", "en", + "--target-device", "mac", + "--minimum-deployment-target", "26.0", + "--platform", "macosx") + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to run actool: %w", err) + } + + // Parse the plist output to verify compilation results + var compilationResults map[string]any + if _, err := plist.Unmarshal(out, &compilationResults); err != nil { + return fmt.Errorf("failed to parse actool compilation results: %w", err) + } + + // Navigate to com.apple.actool.compilation-results -> output-files + compilationData, ok := compilationResults["com.apple.actool.compilation-results"].(map[string]any) + if !ok { + return fmt.Errorf("failed to find com.apple.actool.compilation-results in plist") + } + + outputFiles, ok := compilationData["output-files"].([]any) + if !ok { + return fmt.Errorf("failed to find output-files array in compilation results") + } + + // Check that we have one .car file and one .plist file + var carFile, plistFile, icnsFile string + for _, file := range outputFiles { + filePath, ok := file.(string) + if !ok { + return fmt.Errorf("output file is not a string: %v", file) + } + ext := filepath.Ext(filePath) + switch ext { + case ".car": + carFile = filePath + case ".plist": + plistFile = filePath + case ".icns": + icnsFile = filePath + // Ignore other output files that may be added in future actool versions + } + } + + if carFile == "" { + return fmt.Errorf("no .car file found in output files") + } + if plistFile == "" { + return fmt.Errorf("no .plist file found in output files") + } + if icnsFile == "" { + return fmt.Errorf("no .icns file found in output files") + } + + // Remove the temporary plist file since compilation was successful + if err := os.Remove(plistFile); err != nil { + return fmt.Errorf("failed to remove temporary plist file: %w", err) + } + + // Rename the .icns file to icons.icns + if err := os.Rename(icnsFile, filepath.Join(macAssetDirPath, "icons.icns")); err != nil { + return fmt.Errorf("failed to rename .icns file to icons.icns: %w", err) + } + + return nil +} + func generateWindowsIcon(iconData []byte, sizes []int, options *IconsOptions) error { var output bytes.Buffer diff --git a/v3/internal/commands/icons_test.go b/v3/internal/commands/icons_test.go index 58ae7d6e9..b13823e92 100644 --- a/v3/internal/commands/icons_test.go +++ b/v3/internal/commands/icons_test.go @@ -10,10 +10,11 @@ import ( func TestGenerateIcon(t *testing.T) { tests := []struct { - name string - setup func() *IconsOptions - wantErr bool - test func() error + name string + setup func() *IconsOptions + wantErr bool + requireDarwin bool + test func() error }{ { name: "should generate an icon when using the `example` flag", @@ -123,6 +124,54 @@ func TestGenerateIcon(t *testing.T) { return nil }, }, + + { + name: "should generate a Assets.car and icons.icns file when using the `IconComposerInput` flag and `MacAssetDir` flag", + requireDarwin: true, + setup: func() *IconsOptions { + // Get the directory of this file + _, thisFile, _, _ := runtime.Caller(1) + localDir := filepath.Dir(thisFile) + // Get the path to the example icon + exampleIcon := filepath.Join(localDir, "build_assets", "appicon.icon") + return &IconsOptions{ + IconComposerInput: exampleIcon, + MacAssetDir: localDir, + } + }, + wantErr: false, + test: func() error { + _, thisFile, _, _ := runtime.Caller(1) + localDir := filepath.Dir(thisFile) + carPath := filepath.Join(localDir, "Assets.car") + icnsPath := filepath.Join(localDir, "icons.icns") + defer func() { + _ = os.Remove(carPath) + _ = os.Remove(icnsPath) + }() + f, err := os.Stat(carPath) + if err != nil { + return err + } + if f.IsDir() { + return fmt.Errorf("Assets.car is a directory") + } + if f.Size() == 0 { + return fmt.Errorf("Assets.car is empty") + } + f, err = os.Stat(icnsPath) + if err != nil { + return err + } + if f.IsDir() { + return fmt.Errorf("icons.icns is a directory") + } + if f.Size() == 0 { + return fmt.Errorf("icons.icns is empty") + } + return nil + }, + }, { name: "should generate a small .ico file when using the `input` flag and `sizes` flag", setup: func() *IconsOptions { @@ -266,6 +315,10 @@ func TestGenerateIcon(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.requireDarwin && (runtime.GOOS != "darwin" || os.Getenv("CI") != "") { + t.Skip("Assets.car generation is only supported on macOS and not in CI") + } + options := tt.setup() err := GenerateIcons(options) if (err != nil) != tt.wantErr { diff --git a/v3/internal/commands/updatable_build_assets/darwin/Info.dev.plist.tmpl b/v3/internal/commands/updatable_build_assets/darwin/Info.dev.plist.tmpl index b68a8f1f7..151629739 100644 --- a/v3/internal/commands/updatable_build_assets/darwin/Info.dev.plist.tmpl +++ b/v3/internal/commands/updatable_build_assets/darwin/Info.dev.plist.tmpl @@ -17,6 +17,10 @@ {{.ProductVersion}} CFBundleIconFile icons + {{- if .CFBundleIconName}} + CFBundleIconName + {{.CFBundleIconName}} + {{- end}} LSMinimumSystemVersion 10.15.0 NSHighResolutionCapable diff --git a/v3/internal/commands/updatable_build_assets/darwin/Info.plist.tmpl b/v3/internal/commands/updatable_build_assets/darwin/Info.plist.tmpl index 2767128ff..5792cbf93 100644 --- a/v3/internal/commands/updatable_build_assets/darwin/Info.plist.tmpl +++ b/v3/internal/commands/updatable_build_assets/darwin/Info.plist.tmpl @@ -17,6 +17,10 @@ {{.ProductVersion}} CFBundleIconFile icons + {{- if .CFBundleIconName}} + CFBundleIconName + {{.CFBundleIconName}} + {{- end}} LSMinimumSystemVersion 10.15.0 NSHighResolutionCapable diff --git a/v3/test/docker/Dockerfile.linux-arm64 b/v3/test/docker/Dockerfile.linux-arm64 index fb6e4054b..f4fe2d237 100644 --- a/v3/test/docker/Dockerfile.linux-arm64 +++ b/v3/test/docker/Dockerfile.linux-arm64 @@ -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 diff --git a/v3/test/docker/Dockerfile.linux-x86_64 b/v3/test/docker/Dockerfile.linux-x86_64 index b3a1dcf80..22e7ff586 100644 --- a/v3/test/docker/Dockerfile.linux-x86_64 +++ b/v3/test/docker/Dockerfile.linux-x86_64 @@ -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