diff --git a/docs/src/content/docs/concepts/build-system.mdx b/docs/src/content/docs/concepts/build-system.mdx index c8e502b79b5..16cdf2346f2 100644 --- a/docs/src/content/docs/concepts/build-system.mdx +++ b/docs/src/content/docs/concepts/build-system.mdx @@ -227,12 +227,16 @@ wails3 task --list ### Icons and packaging -Generate platform icons (`build/icons.icns`, `build/icon.ico`, etc.) from a source PNG: +Generate Windows, macOS, and Linux hicolor icons from the canonical source PNG: ```bash -wails3 generate icons -input appicon.png +wails3 generate icons \ + -input build/appicon.png \ + -linuxoutputdir build/linux/icons ``` +Linux PNGs are reproducible generated assets. Fixed-size files are installed into matching hicolor directories by the default DEB, RPM, and Arch package configuration. + Build platform-specific installers/packages: ```bash diff --git a/docs/src/content/docs/guides/build/linux.mdx b/docs/src/content/docs/guides/build/linux.mdx index ea4e4a65524..9283ee752d3 100644 --- a/docs/src/content/docs/guides/build/linux.mdx +++ b/docs/src/content/docs/guides/build/linux.mdx @@ -59,6 +59,19 @@ homepage: https://example.com license: MIT ``` +### Desktop Icons + +`build/appicon.png` is the canonical icon source. The shared `generate:icons` task creates deterministic PNGs in `build/linux/icons/` at 16, 32, 48, 64, 128, 256, and 512 pixels. DEB, RPM, and Arch packages install each generated image into the matching `/usr/share/icons/hicolor/x/apps/` directory. + +The generated nFPM configuration expects that default size set. If you customize `-linuxsizes`, update the icon entries in `build/linux/nfpm/nfpm.yaml` to match. + +Projects created before Linux icon generation keep their compatible legacy package mapping when `update build-assets` runs, because Wails does not overwrite a project's Taskfile. To opt in, update the common `generate:icons` task from a current project so that it passes `-linuxoutputdir linux/icons`, declares every Linux PNG under `generates`, and then run: + +```bash +wails3 task common:generate:icons +wails3 task common:update:build-assets +``` + ### AppImage AppImage configuration is in `build/linux/appimage/`. The app icon comes from `build/appicon.png`. diff --git a/docs/src/content/docs/guides/build/macos.mdx b/docs/src/content/docs/guides/build/macos.mdx index be320829292..092f8d5a53c 100644 --- a/docs/src/content/docs/guides/build/macos.mdx +++ b/docs/src/content/docs/guides/build/macos.mdx @@ -46,7 +46,7 @@ The app icon is generated from assets in the `build/` directory. Use the `genera wails3 task common: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: +This uses `build/appicon.png` to produce `darwin/icons.icns`, `windows/icon.ico`, and the Linux hicolor PNG set. 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 common:update:build-assets @@ -56,7 +56,7 @@ 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 +wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin -linuxoutputdir linux/icons -linuxsizes 16,32,48,64,128,256,512 ``` ## Code Signing diff --git a/docs/src/content/docs/guides/cli.mdx b/docs/src/content/docs/guides/cli.mdx index 4005f244e64..30617af7db5 100644 --- a/docs/src/content/docs/guides/cli.mdx +++ b/docs/src/content/docs/guides/cli.mdx @@ -272,15 +272,34 @@ wails3 generate icons [flags] ``` #### Flags -| Flag | Description | Default | -|------------------------|------------------------------------------------------|-----------------------| -| `-input` | Input PNG file | Required | -| `-windowsfilename` | Windows output filename | | -| `-macfilename` | macOS output filename | | -| `-sizes` | Icon sizes (comma-separated) | `256,128,64,48,32,16` | -| `-example` | Generate example icon | `false` | -| `-iconcomposerinput` | Input Icon Composer file (`.icon`) | | -| `-macassetdir` | Output directory for Mac assets (Assets.car + icns) | | +| Flag | Description | Default | +|------------------------|-------------------------------------------------------|-------------------------------| +| `-input` | Input PNG file | `build/appicon.png` | +| `-windowsfilename` | Windows output filename | `build/windows/icon.ico` | +| `-macfilename` | macOS output filename | `build/darwin/icon.icns` | +| `-sizes` | Windows ICO sizes (comma-separated) | `256,128,64,48,32,16` | +| `-linuxoutputdir` | Output directory for Linux hicolor PNGs | | +| `-linuxsizes` | Linux PNG sizes (comma-separated) | `16,32,48,64,128,256,512` | +| `-example` | Generate example icon | `false` | +| `-iconcomposerinput` | Input Icon Composer file (`.icon`) | | +| `-macassetdir` | Output directory for Mac assets (`Assets.car` + icns) | | + +#### Linux hicolor icons + +Generate a complete Linux desktop icon set without ImageMagick or another external image tool: + +```bash +wails3 generate icons \ + -input build/appicon.png \ + -linuxoutputdir build/linux/icons \ + -linuxsizes 16,32,48,64,128,256,512 +``` + +The command writes files such as `48x48.png` and `128x128.png` to the output directory. Each PNG is an exact square; non-square source images retain their aspect ratio and receive transparent centered padding. Generation is byte-deterministic on every supported build host. + +Treat `build/appicon.png` as the canonical source and the Linux output directory as a disposable generated set. The default `generate:icons` task declares every size as an output, so deleting any one of them regenerates the complete set. + +The default DEB, RPM, and Arch package configuration references all seven default sizes. If you customize `-linuxsizes` in the generated task, update `build/linux/nfpm/nfpm.yaml` to install the same set. #### Icon Composer (macOS) diff --git a/docs/src/content/docs/guides/dev/project-structure.mdx b/docs/src/content/docs/guides/dev/project-structure.mdx index bc9f51787e0..adeaf2cae66 100644 --- a/docs/src/content/docs/guides/dev/project-structure.mdx +++ b/docs/src/content/docs/guides/dev/project-structure.mdx @@ -20,6 +20,7 @@ This page is a simple reference to the files created when you initiate a new pro │ ├── darwin/ # macOS build config │ ├── windows/ # Windows build config │ ├── linux/ # Linux build config +│ │ └── icons/ # Generated hicolor PNG icon set │ ├── android/ # Android build config │ ├── ios/ # iOS build config │ └── docker/ # Containerized build environment @@ -37,6 +38,8 @@ This page is a simple reference to the files created when you initiate a new pro └── .task/ # Task runner cache ``` +Generated directories such as `frontend/dist`, `frontend/bindings`, and `build/linux/icons` appear after their corresponding build or generation task first runs. + :::note[Frontend project files] If you choose to use a different starter template, contents of `frontend/src` will differ depending on your chosen framework -::: \ No newline at end of file +::: diff --git a/docs/src/content/docs/reference/cli.mdx b/docs/src/content/docs/reference/cli.mdx index 62cd453bd40..daa9f957ca4 100644 --- a/docs/src/content/docs/reference/cli.mdx +++ b/docs/src/content/docs/reference/cli.mdx @@ -39,7 +39,7 @@ wails3 --help | Subcommand | Description | |------------|-------------| | `generate bindings` | Generate Go-to-frontend bindings. Flags: `-d` (output dir), `-models`, `-index`, `-ts`, `-i` (interfaces), `-b` (bundle), `-names` (emit `Call.ByName`), `-noevents`, `-noindex`, `-dry`, `-silent`, `-v`, `-clean` (default `true`), `-f`, `-obfuscated` (generate `wails_obfuscated.gen.go` with stable binding IDs for Garble builds; see [Obfuscated Builds](/guides/build/obfuscation)), `-obfuscated-output` (directory for the generated file; defaults to the main package directory). Accepts package patterns (e.g. `./...`); falls back to the current directory if none given. | -| `generate icons` | Convert a source PNG into the platform icon formats. Flags: `-input`, `-windowsfilename`, `-macfilename`, `-iconcomposerinput`, `-macassetdir`. | +| `generate icons` | Convert a source PNG into Windows, macOS, and Linux icon formats. Flags: `-input`, `-windowsfilename`, `-macfilename`, `-sizes` (ICO sizes), `-linuxoutputdir`, `-linuxsizes`, `-iconcomposerinput`, `-macassetdir`. | | `generate build-assets` | Generate the `build/` directory contents (Taskfile snippets, NSIS files, `Info.plist`, `.desktop` template, etc.) from `build/config.yml`. | | `generate runtime` | Regenerate the pre-built `/wails/runtime.js` shipped to the webview. | | `generate syso` | Generate the Windows `.syso` resource file (icon + manifest + version info). | diff --git a/v3/cmd/wails3/README.md b/v3/cmd/wails3/README.md index 8924153dd3c..149f0053a5f 100644 --- a/v3/cmd/wails3/README.md +++ b/v3/cmd/wails3/README.md @@ -17,24 +17,31 @@ It can be used to generate many things including: - resource files for Windows applications - Info.plist files for macOS deployments -#### icon - -The `icon` command generates icons for your project. - -| Flag | Type | Description | Default | -|--------------------|--------|------------------------------------------------------|----------------------| -| `-example` | bool | Generates example icon file (appicon.png) | | -| `-input` | string | The input image file | | -| `-sizes` | string | The sizes to generate in .ico file (comma separated) | "256,128,64,48,32,16" | -| `-windowsFilename` | string | The output filename for the Windows icon | icon.ico | -| `-macFilename` | string | The output filename for the Mac icon bundle | icons.icns | +#### icons + +The `icons` command generates icons for your project. + +| Flag | Type | Description | Default | +|----------------------|--------|----------------------------------------------------------|-------------------------------| +| `-example` | bool | Generate example icon file (`appicon.png`) | | +| `-input` | string | Input PNG file | `build/appicon.png` | +| `-sizes` | string | Windows ICO sizes (comma-separated) | `256,128,64,48,32,16` | +| `-windowsfilename` | string | Windows icon output | `build/windows/icon.ico` | +| `-macfilename` | string | macOS icon bundle output | `build/darwin/icon.icns` | +| `-iconcomposerinput` | string | Input Icon Composer file (`.icon`) | | +| `-macassetdir` | string | Output directory for macOS `Assets.car` and `icons.icns` | | +| `-linuxoutputdir` | string | Output directory for Linux hicolor PNGs | | +| `-linuxsizes` | string | Linux PNG sizes (comma-separated) | `16,32,48,64,128,256,512` | ```bash -wails3 generate icon -input myicon.png -sizes "32,64,128" -windowsFilename myicon.ico -macFilename myicon.icns +wails3 generate icons \ + -input build/appicon.png \ + -windowsfilename build/windows/icon.ico \ + -macfilename build/darwin/icons.icns \ + -linuxoutputdir build/linux/icons ``` -This will generate icons for mac and windows and save them in the current directory as `myicon.ico` -and `myicons.icns`. +Linux images are resized without stretching: non-square input is centered on a transparent square canvas. The PNG output is deterministic and can be regenerated from `build/appicon.png` on any supported build host. #### syso @@ -80,4 +87,4 @@ This will generate all the default assets and resources in the current directory wails3 generate bindings ``` -Generates bindings and models for your bound Go methods and structs. \ No newline at end of file +Generates bindings and models for your bound Go methods and structs. diff --git a/v3/internal/commands/build-assets.go b/v3/internal/commands/build-assets.go index 2b8b3a463d5..ad1f8b65914 100644 --- a/v3/internal/commands/build-assets.go +++ b/v3/internal/commands/build-assets.go @@ -58,6 +58,9 @@ type BuildAssetsOptions struct { type TemplateEnrichment struct { Cls string `description:"A helper for using close template tags safely }}" default:"}}"` Opn string `description:"A helper for using open template tags safely {{" default:"{{"` + // LinuxHicolorIcons indicates that the project's preserved common Taskfile + // generates the complete Linux hicolor icon set used by the nfpm template. + LinuxHicolorIcons bool `yaml:"-"` // BackgroundModes feeds the iOS Info.plist template's UIBackgroundModes block. // It's populated by the iOS Xcode generator from ios.backgroundModes in // build/config.yml; the generic build-assets path leaves it empty (like @@ -113,6 +116,7 @@ func GenerateBuildAssets(options *BuildAssetsOptions) error { var config BuildConfig config.Cls = "}}" config.Opn = "{{" + config.LinuxHicolorIcons = true if options.ProductComments == "" { options.ProductComments = fmt.Sprintf("(c) %d %s", time.Now().Year(), options.ProductCompany) @@ -278,6 +282,7 @@ func UpdateBuildAssets(options *UpdateBuildAssetsOptions) error { config.Cls = "}}" config.Opn = "{{" + config.LinuxHicolorIcons = taskfileSupportsLinuxHicolorIcons(options.Dir) // If directory doesn't exist, create it if _, err := os.Stat(options.Dir); os.IsNotExist(err) { @@ -332,6 +337,26 @@ func UpdateBuildAssets(options *UpdateBuildAssetsOptions) error { return nil } +func taskfileSupportsLinuxHicolorIcons(buildDir string) bool { + data, err := os.ReadFile(filepath.Join(buildDir, "Taskfile.yml")) + if err != nil { + return false + } + content := string(data) + if !strings.Contains(content, "-linuxoutputdir linux/icons") { + return false + } + if strings.Contains(content, "-linuxsizes") && !strings.Contains(content, "-linuxsizes "+defaultLinuxIconSizes) { + return false + } + for _, size := range strings.Split(defaultLinuxIconSizes, ",") { + if !strings.Contains(content, "linux/icons/"+size+"x"+size+".png") { + return false + } + } + return true +} + func normaliseName(name string) string { return strings.ToLower(strings.ReplaceAll(name, " ", "-")) } diff --git a/v3/internal/commands/build_assets/Taskfile.tmpl.yml b/v3/internal/commands/build_assets/Taskfile.tmpl.yml index 65e1eb78a8e..f97c59412cd 100644 --- a/v3/internal/commands/build_assets/Taskfile.tmpl.yml +++ b/v3/internal/commands/build_assets/Taskfile.tmpl.yml @@ -191,7 +191,7 @@ tasks: - wails3 generate bindings -f '{{.Opn}}.BUILD_FLAGS{{.Cls}}' -clean=true{{if not (and .UseInterfaces .Typescript)}} -time-type=Date{{end}}{{.Opn}}if eq .OBFUSCATED "true"{{.Cls}} -obfuscated{{.Opn}}end{{.Cls}}{{if .Typescript}} -ts{{end}}{{if and .UseInterfaces .Typescript}} -i{{end}} generate:icons: - 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). + summary: Generates Windows `.ico`, Mac `.icns`, and Linux hicolor PNGs from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms). run: once dir: build sources: @@ -200,8 +200,15 @@ tasks: generates: - "darwin/icons.icns" - "windows/icon.ico" + - "linux/icons/16x16.png" + - "linux/icons/32x32.png" + - "linux/icons/48x48.png" + - "linux/icons/64x64.png" + - "linux/icons/128x128.png" + - "linux/icons/256x256.png" + - "linux/icons/512x512.png" cmds: - - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin + - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin -linuxoutputdir linux/icons -linuxsizes 16,32,48,64,128,256,512 dev:frontend: summary: Runs the frontend in development mode diff --git a/v3/internal/commands/icons.go b/v3/internal/commands/icons.go index 6d97302d287..e52061d38c9 100644 --- a/v3/internal/commands/icons.go +++ b/v3/internal/commands/icons.go @@ -38,6 +38,8 @@ type IconsOptions struct { MacFilename string `description:"The output filename for the Mac icon bundle" default:"build/darwin/icon.icns"` IconComposerInput string `description:"The input Icon Composer file (.icon)"` MacAssetDir string `description:"The output directory for the Mac assets (Assets.car and icons.icns)"` + LinuxOutputDir string `description:"The output directory for Linux hicolor PNG icons"` + LinuxSizes string `description:"The sizes to generate as Linux PNG icons (comma separated)" default:"16,32,48,64,128,256,512"` } func GenerateIcons(options *IconsOptions) error { @@ -50,9 +52,12 @@ func GenerateIcons(options *IconsOptions) error { if options.Input == "" && options.IconComposerInput == "" { return fmt.Errorf("either input or icon composer input is required") } + if options.LinuxOutputDir != "" && options.Input == "" { + return fmt.Errorf("input is required for Linux icon generation") + } - if options.Input != "" && options.WindowsFilename == "" && options.MacFilename == "" { - return fmt.Errorf("either windows filename or mac filename is required") + if options.Input != "" && options.WindowsFilename == "" && options.MacFilename == "" && options.LinuxOutputDir == "" { + return fmt.Errorf("at least one Windows, Mac, or Linux output is required") } if options.IconComposerInput != "" && options.MacAssetDir == "" { @@ -69,6 +74,14 @@ func GenerateIcons(options *IconsOptions) error { } } + var linuxSizes []int + if options.LinuxOutputDir != "" { + linuxSizes, err = parseLinuxIconSizes(options.LinuxSizes) + if err != nil { + return err + } + } + // Generate Icons from Icon Composer input macIconsGenerated := false if options.IconComposerInput != "" { @@ -112,6 +125,12 @@ func GenerateIcons(options *IconsOptions) error { return err } } + + if options.LinuxOutputDir != "" { + if err := generateLinuxIcons(iconData, linuxSizes, options.LinuxOutputDir); err != nil { + return err + } + } } return nil diff --git a/v3/internal/commands/linux_icons.go b/v3/internal/commands/linux_icons.go new file mode 100644 index 00000000000..38093829d27 --- /dev/null +++ b/v3/internal/commands/linux_icons.go @@ -0,0 +1,294 @@ +package commands + +import ( + "bytes" + "fmt" + "image" + "image/png" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "golang.org/x/image/draw" +) + +const ( + defaultLinuxIconSizes = "16,32,48,64,128,256,512" + maxLinuxIconSize = 4096 +) + +func parseLinuxIconSizes(value string) ([]int, error) { + if strings.TrimSpace(value) == "" { + value = defaultLinuxIconSizes + } + + seen := make(map[int]struct{}) + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part == "" { + return nil, fmt.Errorf("invalid Linux icon sizes %q: sizes must be positive integers", value) + } + + size, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("invalid Linux icon size %q: %w", part, err) + } + if size <= 0 || size > maxLinuxIconSize { + return nil, fmt.Errorf("invalid Linux icon size %d: must be between 1 and %d", size, maxLinuxIconSize) + } + seen[size] = struct{}{} + } + + sizes := make([]int, 0, len(seen)) + for size := range seen { + sizes = append(sizes, size) + } + sort.Ints(sizes) + return sizes, nil +} + +func generateLinuxIcons(iconData []byte, sizes []int, outputDir string) error { + if err := validateLinuxIconOutputDir(outputDir); err != nil { + return err + } + outputDir = filepath.Clean(outputDir) + if len(sizes) == 0 { + return fmt.Errorf("at least one Linux icon size is required") + } + for _, size := range sizes { + if size <= 0 || size > maxLinuxIconSize { + return fmt.Errorf("invalid Linux icon size %d: must be between 1 and %d", size, maxLinuxIconSize) + } + } + + parent := filepath.Dir(outputDir) + base := filepath.Base(outputDir) + // Keep the previous complete set in a predictable sibling so the next run + // can restore it if the process stops between the two directory renames. + backupDir := filepath.Join(parent, "."+base+".wails-backup") + if err := recoverLinuxIconOutput(outputDir, backupDir); err != nil { + return err + } + if err := validateExistingLinuxIconOutput(outputDir); err != nil { + return err + } + + source, _, err := image.Decode(bytes.NewReader(iconData)) + if err != nil { + return fmt.Errorf("decode Linux icon source: %w", err) + } + if source.Bounds().Dx() <= 0 || source.Bounds().Dy() <= 0 { + return fmt.Errorf("decode Linux icon source: image has empty bounds") + } + + if err := os.MkdirAll(parent, 0o755); err != nil { + return fmt.Errorf("create Linux icon parent directory: %w", err) + } + + // A sibling staging directory keeps publication on one filesystem. Its + // unique name also prevents concurrent invocations from deleting each + // other's in-progress encodes. + stagingDir, err := os.MkdirTemp(parent, "."+base+".wails-staging-") + if err != nil { + return fmt.Errorf("create Linux icon staging directory: %w", err) + } + defer os.RemoveAll(stagingDir) + if err := os.Chmod(stagingDir, 0o755); err != nil { + return fmt.Errorf("set Linux icon staging directory permissions: %w", err) + } + + for _, size := range sizes { + filename := filepath.Join(stagingDir, linuxIconFilename(size)) + if err := encodeLinuxIcon(filename, resizeAndPadIcon(source, size)); err != nil { + return err + } + } + + if err := publishLinuxIconOutput(outputDir, stagingDir, backupDir); err != nil { + return err + } + return nil +} + +func validateLinuxIconOutputDir(outputDir string) error { + if strings.TrimSpace(outputDir) == "" { + return fmt.Errorf("Linux icon output directory is required") + } + + clean := filepath.Clean(outputDir) + volumeRoot := filepath.VolumeName(clean) + string(filepath.Separator) + if clean == "." || clean == ".." || clean == string(filepath.Separator) || clean == volumeRoot || filepath.Base(clean) == "." || filepath.Base(clean) == ".." { + return fmt.Errorf("Linux icon output directory must be a dedicated subdirectory") + } + return nil +} + +func recoverLinuxIconOutput(outputDir, backupDir string) error { + outputExists, err := pathExists(outputDir) + if err != nil { + return fmt.Errorf("inspect Linux icon output directory: %w", err) + } + backupExists, err := pathExists(backupDir) + if err != nil { + return fmt.Errorf("inspect Linux icon backup directory: %w", err) + } + + if backupExists { + if err := validateExistingLinuxIconOutput(backupDir); err != nil { + return fmt.Errorf("inspect Linux icon backup directory: %w", err) + } + if outputExists { + if err := validateExistingLinuxIconOutput(outputDir); err != nil { + return err + } + if err := os.RemoveAll(backupDir); err != nil { + return fmt.Errorf("remove committed Linux icon backup: %w", err) + } + } else if err := os.Rename(backupDir, outputDir); err != nil { + return fmt.Errorf("restore Linux icon backup: %w", err) + } + } + return nil +} + +func validateExistingLinuxIconOutput(outputDir string) error { + info, err := os.Lstat(outputDir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("inspect Linux icon output directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("Linux icon output path %q is not a directory", outputDir) + } + + entries, err := os.ReadDir(outputDir) + if err != nil { + return fmt.Errorf("inspect Linux icon output directory: %w", err) + } + for _, entry := range entries { + if !entry.Type().IsRegular() || !isLinuxIconFilename(entry.Name()) { + return fmt.Errorf("refusing to replace Linux icon output directory %q: unexpected entry %q", outputDir, entry.Name()) + } + } + return nil +} + +func isLinuxIconFilename(filename string) bool { + stem, found := strings.CutSuffix(filename, ".png") + if !found { + return false + } + width, height, found := strings.Cut(stem, "x") + if !found || width != height { + return false + } + size, err := strconv.Atoi(width) + return err == nil && size > 0 && size <= maxLinuxIconSize && strconv.Itoa(size) == width +} + +func publishLinuxIconOutput(outputDir, stagingDir, backupDir string) error { + // Portable filesystems do not provide an atomic exchange for non-empty + // directories. The two renames may briefly leave outputDir absent, but both + // the staging and backup directories always contain complete sets, and the + // next invocation restores the backup after an interrupted replacement. + outputExists, err := pathExists(outputDir) + if err != nil { + return fmt.Errorf("inspect Linux icon output directory: %w", err) + } + if !outputExists { + if err := os.Rename(stagingDir, outputDir); err != nil { + return fmt.Errorf("publish Linux icons: %w", err) + } + return nil + } + + if err := validateExistingLinuxIconOutput(outputDir); err != nil { + return err + } + + if err := os.Rename(outputDir, backupDir); err != nil { + return fmt.Errorf("prepare Linux icon output replacement: %w", err) + } + if err := os.Rename(stagingDir, outputDir); err != nil { + if rollbackErr := os.Rename(backupDir, outputDir); rollbackErr != nil { + return fmt.Errorf("publish Linux icons: %w (restore previous icons: %v)", err, rollbackErr) + } + return fmt.Errorf("publish Linux icons: %w", err) + } + if err := os.RemoveAll(backupDir); err != nil { + return fmt.Errorf("remove previous Linux icons: %w", err) + } + return nil +} + +func pathExists(path string) (bool, error) { + _, err := os.Lstat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func resizeAndPadIcon(source image.Image, size int) *image.NRGBA { + sourceBounds := source.Bounds() + sourceWidth := sourceBounds.Dx() + sourceHeight := sourceBounds.Dy() + + targetWidth := size + targetHeight := size + if sourceWidth > sourceHeight { + targetHeight = scaledIconDimension(sourceHeight, sourceWidth, size) + } else if sourceHeight > sourceWidth { + targetWidth = scaledIconDimension(sourceWidth, sourceHeight, size) + } + + x := (size - targetWidth) / 2 + y := (size - targetHeight) / 2 + target := image.NewNRGBA(image.Rect(0, 0, size, size)) + draw.CatmullRom.Scale( + target, + image.Rect(x, y, x+targetWidth, y+targetHeight), + source, + sourceBounds, + draw.Src, + nil, + ) + return target +} + +func scaledIconDimension(sourceDimension, sourceMaximum, targetMaximum int) int { + scaled := (int64(sourceDimension)*int64(targetMaximum) + int64(sourceMaximum)/2) / int64(sourceMaximum) + if scaled < 1 { + return 1 + } + return int(scaled) +} + +func encodeLinuxIcon(filename string, icon image.Image) (err error) { + file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return fmt.Errorf("create Linux icon %q: %w", filename, err) + } + defer func() { + if closeErr := file.Close(); err == nil && closeErr != nil { + err = fmt.Errorf("close Linux icon %q: %w", filename, closeErr) + } + }() + + encoder := png.Encoder{CompressionLevel: png.BestCompression} + if err := encoder.Encode(file, icon); err != nil { + return fmt.Errorf("encode Linux icon %q: %w", filename, err) + } + return nil +} + +func linuxIconFilename(size int) string { + return fmt.Sprintf("%dx%d.png", size, size) +} diff --git a/v3/internal/commands/linux_icons_assets_test.go b/v3/internal/commands/linux_icons_assets_test.go new file mode 100644 index 00000000000..dbfb3f79a78 --- /dev/null +++ b/v3/internal/commands/linux_icons_assets_test.go @@ -0,0 +1,224 @@ +package commands + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goreleaser/nfpm/v2" + "github.com/goreleaser/nfpm/v2/files" +) + +func TestFreshBuildAssetsEnableLinuxHicolorIcons(t *testing.T) { + buildDir := filepath.Join(t.TempDir(), "build") + options := &BuildAssetsOptions{ + Dir: buildDir, + Name: "Test App", + BinaryName: "test-app", + ProductName: "Test App", + ProductDescription: "Test application", + ProductVersion: "1.0.0", + ProductCompany: "Test Company", + Silent: true, + } + if err := GenerateBuildAssets(options); err != nil { + t.Fatalf("GenerateBuildAssets() error = %v", err) + } + + taskfile := readTextFile(t, filepath.Join(buildDir, "Taskfile.yml")) + if !strings.Contains(taskfile, "-linuxoutputdir linux/icons") { + t.Error("generated Taskfile does not enable Linux icon generation") + } + if !strings.Contains(taskfile, "-linuxsizes "+defaultLinuxIconSizes) { + t.Error("generated Taskfile Linux sizes do not match the command default") + } + + sizes, err := parseLinuxIconSizes("") + if err != nil { + t.Fatal(err) + } + for _, size := range sizes { + output := fmt.Sprintf(`"linux/icons/%dx%d.png"`, size, size) + if !strings.Contains(taskfile, output) { + t.Errorf("generated Taskfile does not declare output %s", output) + } + } + + nfpm := readTextFile(t, filepath.Join(buildDir, "linux", "nfpm", "nfpm.yaml")) + assertNfpmHicolorEntries(t, nfpm, "test-app", sizes) + if strings.Contains(nfpm, `src: "./build/appicon.png"`) { + t.Error("fresh nfpm config contains the legacy mismatched 128x128 icon mapping") + } + iconData, err := os.ReadFile(filepath.Join(buildDir, "appicon.png")) + if err != nil { + t.Fatal(err) + } + if err := generateLinuxIcons(iconData, sizes, filepath.Join(buildDir, "linux", "icons")); err != nil { + t.Fatalf("generate package icons: %v", err) + } + if err := os.MkdirAll(filepath.Join(filepath.Dir(buildDir), "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(filepath.Dir(buildDir), "bin", "test-app"), []byte("test binary"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(buildDir, "linux", "test-app.desktop"), []byte("[Desktop Entry]\nName=Test App\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(filepath.Dir(buildDir)) + assertPreparedPackageContentsContainHicolorIcons(t, filepath.Join(buildDir, "linux", "nfpm", "nfpm.yaml"), "test-app", sizes) +} + +func TestUpdateBuildAssetsKeepsLegacyLinuxIconMappingWithoutTaskSupport(t *testing.T) { + buildDir := filepath.Join(t.TempDir(), "build") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(buildDir, "Taskfile.yml"), []byte("tasks:\n generate:icons:\n cmds: []\n"), 0o644); err != nil { + t.Fatal(err) + } + + updateBuildAssetsForIconTest(t, buildDir) + nfpm := readTextFile(t, filepath.Join(buildDir, "linux", "nfpm", "nfpm.yaml")) + if !strings.Contains(nfpm, `src: "./build/appicon.png"`) { + t.Error("updated legacy project did not retain its compatible appicon.png mapping") + } + if strings.Contains(nfpm, "./build/linux/icons/") { + t.Error("updated legacy project references Linux icons its Taskfile cannot generate") + } +} + +func TestUpdateBuildAssetsEnablesLinuxIconMappingsForCapableTaskfile(t *testing.T) { + buildDir := filepath.Join(t.TempDir(), "build") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + t.Fatal(err) + } + sizes, err := parseLinuxIconSizes("") + if err != nil { + t.Fatal(err) + } + var capableTaskfile strings.Builder + capableTaskfile.WriteString("tasks:\n generate:icons:\n generates:\n") + for _, size := range sizes { + fmt.Fprintf(&capableTaskfile, " - \"linux/icons/%dx%d.png\"\n", size, size) + } + fmt.Fprintf(&capableTaskfile, " cmds:\n - wails3 generate icons -linuxoutputdir linux/icons -linuxsizes %s\n", defaultLinuxIconSizes) + if err := os.WriteFile(filepath.Join(buildDir, "Taskfile.yml"), []byte(capableTaskfile.String()), 0o644); err != nil { + t.Fatal(err) + } + + updateBuildAssetsForIconTest(t, buildDir) + nfpm := readTextFile(t, filepath.Join(buildDir, "linux", "nfpm", "nfpm.yaml")) + assertNfpmHicolorEntries(t, nfpm, "test-app", sizes) + if strings.Contains(nfpm, `src: "./build/appicon.png"`) { + t.Error("capable project retained the legacy icon mapping") + } +} + +func TestTaskfileSupportsLinuxHicolorIconsIsConservative(t *testing.T) { + t.Run("missing", func(t *testing.T) { + if taskfileSupportsLinuxHicolorIcons(t.TempDir()) { + t.Error("missing Taskfile reported Linux hicolor support") + } + }) + + t.Run("unreadable as file", func(t *testing.T) { + buildDir := t.TempDir() + if err := os.Mkdir(filepath.Join(buildDir, "Taskfile.yml"), 0o755); err != nil { + t.Fatal(err) + } + if taskfileSupportsLinuxHicolorIcons(buildDir) { + t.Error("unreadable Taskfile reported Linux hicolor support") + } + }) + + t.Run("requires flag and output", func(t *testing.T) { + buildDir := t.TempDir() + if err := os.WriteFile(filepath.Join(buildDir, "Taskfile.yml"), []byte("-linuxoutputdir linux/icons\n"), 0o644); err != nil { + t.Fatal(err) + } + if taskfileSupportsLinuxHicolorIcons(buildDir) { + t.Error("Taskfile without declared Linux output reported support") + } + }) + + t.Run("requires every default output", func(t *testing.T) { + buildDir := t.TempDir() + content := "-linuxoutputdir linux/icons -linuxsizes 512\nlinux/icons/512x512.png\n" + if err := os.WriteFile(filepath.Join(buildDir, "Taskfile.yml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if taskfileSupportsLinuxHicolorIcons(buildDir) { + t.Error("Taskfile with only one Linux size reported complete hicolor support") + } + }) +} + +func updateBuildAssetsForIconTest(t *testing.T, buildDir string) { + t.Helper() + options := &UpdateBuildAssetsOptions{ + Dir: buildDir, + Name: "Test App", + BinaryName: "test-app", + ProductName: "Test App", + ProductDescription: "Test application", + ProductVersion: "1.0.0", + ProductCompany: "Test Company", + Silent: true, + } + if err := UpdateBuildAssets(options); err != nil { + t.Fatalf("UpdateBuildAssets() error = %v", err) + } +} + +func assertNfpmHicolorEntries(t *testing.T, content, binaryName string, sizes []int) { + t.Helper() + for _, size := range sizes { + source := fmt.Sprintf(`src: "./build/linux/icons/%dx%d.png"`, size, size) + destination := fmt.Sprintf(`dst: "/usr/share/icons/hicolor/%dx%d/apps/%s.png"`, size, size, binaryName) + if !strings.Contains(content, source) { + t.Errorf("nfpm config missing %s", source) + } + if !strings.Contains(content, destination) { + t.Errorf("nfpm config missing %s", destination) + } + } +} + +func assertPreparedPackageContentsContainHicolorIcons(t *testing.T, configPath, binaryName string, sizes []int) { + t.Helper() + t.Setenv("GOARCH", "amd64") + t.Setenv("GIT_COMMITTER_NAME", "Wails Test") + t.Setenv("GIT_COMMITTER_EMAIL", "test@example.com") + config, err := nfpm.ParseFile(configPath) + if err != nil { + t.Fatalf("parse generated nfpm config: %v", err) + } + for _, format := range []string{"deb", "rpm", "archlinux"} { + info, err := config.Get(format) + if err != nil { + t.Fatalf("resolve %s package config: %v", format, err) + } + prepared, err := files.PrepareForPackager(info.Contents, info.Umask, format, info.DisableGlobbing, info.MTime) + if err != nil { + t.Fatalf("prepare %s package contents: %v", format, err) + } + for _, size := range sizes { + destination := fmt.Sprintf("/usr/share/icons/hicolor/%dx%d/apps/%s.png", size, size, binaryName) + if !prepared.ContainsDestination(destination) { + t.Errorf("%s package config does not contain %s", format, destination) + } + } + } +} + +func readTextFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} diff --git a/v3/internal/commands/linux_icons_test.go b/v3/internal/commands/linux_icons_test.go new file mode 100644 index 00000000000..17723568f53 --- /dev/null +++ b/v3/internal/commands/linux_icons_test.go @@ -0,0 +1,287 @@ +package commands + +import ( + "bytes" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestParseLinuxIconSizes(t *testing.T) { + tests := []struct { + name string + value string + want []int + wantErr bool + }{ + {name: "defaults", want: []int{16, 32, 48, 64, 128, 256, 512}}, + {name: "sorts and deduplicates", value: "64, 16,32,16", want: []int{16, 32, 64}}, + {name: "empty element", value: "16,,32", wantErr: true}, + {name: "not a number", value: "16,large", wantErr: true}, + {name: "zero", value: "0", wantErr: true}, + {name: "negative", value: "-16", wantErr: true}, + {name: "too large", value: "4097", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := parseLinuxIconSizes(test.value) + if (err != nil) != test.wantErr { + t.Fatalf("parseLinuxIconSizes(%q) error = %v, wantErr %v", test.value, err, test.wantErr) + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("parseLinuxIconSizes(%q) = %v, want %v", test.value, got, test.want) + } + }) + } +} + +func TestIsLinuxIconFilename(t *testing.T) { + tests := map[string]bool{ + "16x16.png": true, + "4096x4096.png": true, + "0x0.png": false, + "016x016.png": false, + "16x32.png": false, + "4097x4097.png": false, + "16x16.jpg": false, + "README.txt": false, + } + for filename, want := range tests { + if got := isLinuxIconFilename(filename); got != want { + t.Errorf("isLinuxIconFilename(%q) = %v, want %v", filename, got, want) + } + } +} + +func TestGenerateLinuxIconsDimensionsPaddingAndDeterminism(t *testing.T) { + iconData := encodeTestPNG(t, 40, 20, color.NRGBA{R: 220, G: 40, B: 10, A: 128}) + outputDir := filepath.Join(t.TempDir(), "icons") + sizes := []int{16, 32} + + if err := generateLinuxIcons(iconData, sizes, outputDir); err != nil { + t.Fatalf("generateLinuxIcons() error = %v", err) + } + firstRun := readGeneratedIcons(t, outputDir, sizes) + + for _, size := range sizes { + filename := linuxIconFilename(size) + generated, err := png.Decode(bytes.NewReader(firstRun[filename])) + if err != nil { + t.Fatalf("decode %s: %v", filename, err) + } + if got, want := generated.Bounds(), image.Rect(0, 0, size, size); got != want { + t.Errorf("%s bounds = %v, want %v", filename, got, want) + } + + nrgba, ok := generated.(*image.NRGBA) + if !ok { + t.Fatalf("%s decoded as %T, want *image.NRGBA", filename, generated) + } + if alpha := nrgba.NRGBAAt(0, 0).A; alpha != 0 { + t.Errorf("%s corner alpha = %d, want transparent padding", filename, alpha) + } + if alpha := nrgba.NRGBAAt(size/2, size/2).A; alpha == 0 || alpha == 255 { + t.Errorf("%s center alpha = %d, want preserved source transparency", filename, alpha) + } + } + + if err := generateLinuxIcons(iconData, sizes, outputDir); err != nil { + t.Fatalf("second generateLinuxIcons() error = %v", err) + } + secondRun := readGeneratedIcons(t, outputDir, sizes) + if !reflect.DeepEqual(secondRun, firstRun) { + t.Error("identical input produced different Linux PNG bytes") + } +} + +func TestGenerateLinuxIconsRecoversInterruptedReplacement(t *testing.T) { + iconData := encodeTestPNG(t, 20, 40, color.NRGBA{R: 10, G: 80, B: 230, A: 255}) + outputDir := filepath.Join(t.TempDir(), "icons") + backupDir := filepath.Join(filepath.Dir(outputDir), ".icons.wails-backup") + + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backupDir, "32x32.png"), []byte("previous complete set"), 0o644); err != nil { + t.Fatal(err) + } + + if err := generateLinuxIcons(iconData, []int{24, 48}, outputDir); err != nil { + t.Fatalf("generateLinuxIcons() error = %v", err) + } + readGeneratedIcons(t, outputDir, []int{24, 48}) + if _, err := os.Stat(backupDir); !os.IsNotExist(err) { + t.Errorf("recovery artifact %q still exists; stat error = %v", backupDir, err) + } +} + +func TestGenerateLinuxIconsRestoresBackupBeforeDecoding(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "icons") + backupDir := filepath.Join(filepath.Dir(outputDir), ".icons.wails-backup") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatal(err) + } + previous := []byte("previous complete set") + if err := os.WriteFile(filepath.Join(backupDir, "32x32.png"), previous, 0o644); err != nil { + t.Fatal(err) + } + + if err := generateLinuxIcons([]byte("not an image"), []int{32}, outputDir); err == nil { + t.Fatal("generateLinuxIcons() error = nil, want decode error") + } + got, err := os.ReadFile(filepath.Join(outputDir, "32x32.png")) + if err != nil { + t.Fatalf("read restored icon: %v", err) + } + if !bytes.Equal(got, previous) { + t.Errorf("restored icon = %q, want %q", got, previous) + } + if _, err := os.Stat(backupDir); !os.IsNotExist(err) { + t.Errorf("backup directory still exists; stat error = %v", err) + } +} + +func TestGenerateLinuxIconsFailureLeavesExistingSetUntouched(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "icons") + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatal(err) + } + existingPath := filepath.Join(outputDir, "32x32.png") + existing := []byte("existing complete output") + if err := os.WriteFile(existingPath, existing, 0o644); err != nil { + t.Fatal(err) + } + + if err := generateLinuxIcons([]byte("not an image"), []int{32}, outputDir); err == nil { + t.Fatal("generateLinuxIcons() error = nil, want decode error") + } + got, err := os.ReadFile(existingPath) + if err != nil { + t.Fatalf("read existing icon after failure: %v", err) + } + if !bytes.Equal(got, existing) { + t.Errorf("existing icon changed after failed generation: got %q, want %q", got, existing) + } +} + +func TestGenerateLinuxIconsRefusesDirectoryWithUnrelatedFiles(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "icons") + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatal(err) + } + unrelatedPath := filepath.Join(outputDir, "README.txt") + if err := os.WriteFile(unrelatedPath, []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + + iconData := encodeTestPNG(t, 32, 32, color.NRGBA{R: 50, G: 180, B: 90, A: 255}) + if err := generateLinuxIcons(iconData, []int{16}, outputDir); err == nil { + t.Fatal("generateLinuxIcons() error = nil, want unsafe replacement error") + } + if got, err := os.ReadFile(unrelatedPath); err != nil || string(got) != "keep me" { + t.Errorf("unrelated file was changed: content = %q, error = %v", got, err) + } +} + +func TestGenerateIconsLinuxOnlyRegeneratesMissingSize(t *testing.T) { + tempDir := t.TempDir() + input := filepath.Join(tempDir, "source.png") + if err := os.WriteFile(input, encodeTestPNG(t, 32, 32, color.NRGBA{R: 50, G: 180, B: 90, A: 255}), 0o644); err != nil { + t.Fatal(err) + } + outputDir := filepath.Join(tempDir, "icons") + options := &IconsOptions{ + Input: input, + LinuxOutputDir: outputDir, + LinuxSizes: "16,32", + } + + if err := GenerateIcons(options); err != nil { + t.Fatalf("GenerateIcons() error = %v", err) + } + missing := filepath.Join(outputDir, "16x16.png") + if err := os.Remove(missing); err != nil { + t.Fatal(err) + } + if err := GenerateIcons(options); err != nil { + t.Fatalf("GenerateIcons() after missing output error = %v", err) + } + readGeneratedIcons(t, outputDir, []int{16, 32}) +} + +func TestGenerateIconsRequiresRasterInputForLinux(t *testing.T) { + options := &IconsOptions{ + IconComposerInput: "appicon.icon", + MacAssetDir: t.TempDir(), + LinuxOutputDir: filepath.Join(t.TempDir(), "icons"), + } + + err := GenerateIcons(options) + if err == nil || !strings.Contains(err.Error(), "input is required for Linux icon generation") { + t.Fatalf("GenerateIcons() error = %v, want Linux raster input error", err) + } +} + +func TestGenerateLinuxIconsCleansOutputDirectoryPath(t *testing.T) { + tempDir := t.TempDir() + outputDir := filepath.Join(tempDir, "parent", "..", "icons") + string(filepath.Separator) + iconData := encodeTestPNG(t, 32, 32, color.NRGBA{R: 50, G: 180, B: 90, A: 255}) + + if err := generateLinuxIcons(iconData, []int{16}, outputDir); err != nil { + t.Fatalf("generateLinuxIcons() error = %v", err) + } + readGeneratedIcons(t, filepath.Join(tempDir, "icons"), []int{16}) + entries, err := os.ReadDir(tempDir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".icons.wails-staging-") { + t.Errorf("staging directory %q still exists", entry.Name()) + } + } +} + +func encodeTestPNG(t *testing.T, width, height int, fill color.NRGBA) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.SetNRGBA(x, y, fill) + } + } + + var data bytes.Buffer + if err := png.Encode(&data, img); err != nil { + t.Fatalf("encode source PNG: %v", err) + } + return data.Bytes() +} + +func readGeneratedIcons(t *testing.T, outputDir string, sizes []int) map[string][]byte { + t.Helper() + entries, err := os.ReadDir(outputDir) + if err != nil { + t.Fatalf("read output directory: %v", err) + } + if got, want := len(entries), len(sizes); got != want { + t.Fatalf("output entry count = %d, want %d", got, want) + } + + result := make(map[string][]byte, len(sizes)) + for _, size := range sizes { + filename := linuxIconFilename(size) + data, err := os.ReadFile(filepath.Join(outputDir, filename)) + if err != nil { + t.Fatalf("read %s: %v", filename, err) + } + result[filename] = data + } + return result +} diff --git a/v3/internal/commands/updatable_build_assets/linux/nfpm/nfpm.yaml.tmpl b/v3/internal/commands/updatable_build_assets/linux/nfpm/nfpm.yaml.tmpl index c40471d5782..6cde9e90e06 100644 --- a/v3/internal/commands/updatable_build_assets/linux/nfpm/nfpm.yaml.tmpl +++ b/v3/internal/commands/updatable_build_assets/linux/nfpm/nfpm.yaml.tmpl @@ -19,8 +19,25 @@ release: "1" contents: - src: "./bin/{{.BinaryName}}" dst: "/usr/local/bin/{{.BinaryName}}" +{{if .LinuxHicolorIcons}} + - src: "./build/linux/icons/16x16.png" + dst: "/usr/share/icons/hicolor/16x16/apps/{{.BinaryName}}.png" + - src: "./build/linux/icons/32x32.png" + dst: "/usr/share/icons/hicolor/32x32/apps/{{.BinaryName}}.png" + - src: "./build/linux/icons/48x48.png" + dst: "/usr/share/icons/hicolor/48x48/apps/{{.BinaryName}}.png" + - src: "./build/linux/icons/64x64.png" + dst: "/usr/share/icons/hicolor/64x64/apps/{{.BinaryName}}.png" + - src: "./build/linux/icons/128x128.png" + dst: "/usr/share/icons/hicolor/128x128/apps/{{.BinaryName}}.png" + - src: "./build/linux/icons/256x256.png" + dst: "/usr/share/icons/hicolor/256x256/apps/{{.BinaryName}}.png" + - src: "./build/linux/icons/512x512.png" + dst: "/usr/share/icons/hicolor/512x512/apps/{{.BinaryName}}.png" +{{else}} - src: "./build/appicon.png" dst: "/usr/share/icons/hicolor/128x128/apps/{{.BinaryName}}.png" +{{end}} - src: "./build/linux/{{.BinaryName}}.desktop" dst: "/usr/share/applications/{{.BinaryName}}.desktop"