From 7927c5b8a6bf817bd70c2d37c1a4b9c20539c7c1 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sat, 1 Aug 2026 11:18:57 +1000 Subject: [PATCH 1/2] feat(v3): make DMG icon layout configurable --- docs/src/content/docs/guides/build/macos.mdx | 6 ++ .../commands/build_assets/darwin/Taskfile.yml | 4 + v3/internal/commands/tool_package.go | 88 ++++++++++++++++--- v3/internal/commands/tool_package_test.go | 53 ++++++++--- v3/internal/flags/package.go | 24 ++--- 5 files changed, 143 insertions(+), 32 deletions(-) diff --git a/docs/src/content/docs/guides/build/macos.mdx b/docs/src/content/docs/guides/build/macos.mdx index be320829292..ee03e4460b9 100644 --- a/docs/src/content/docs/guides/build/macos.mdx +++ b/docs/src/content/docs/guides/build/macos.mdx @@ -122,6 +122,10 @@ vars: DMG_BACKGROUND: build/darwin/dmg-background.png DMG_VOLUME_ICON: build/darwin/icons.icns DMG_FILE_ICON: build/darwin/dmg-file-icon.icns + # auto lets the DMG library distribute every icon. For manual placement, + # use `manual` and specify every position as name=x,y;name=x,y. + DMG_ICON_LAYOUT: auto + DMG_ICON_POSITIONS: "" DMG_WINDOW_WIDTH: 540 DMG_WINDOW_HEIGHT: 380 DMG_FILES: "Install.command=build/darwin/Install.command,README.txt=README.md" @@ -166,6 +170,8 @@ Each displayed name must be unique. Extra files cannot replace entries already c DMG creation is supported only on macOS because it uses macOS disk-image and Finder tooling. Cross-compiled `.app` bundles can be created elsewhere, but the final DMG must be produced on a Mac. +Set `DMG_ICON_LAYOUT` to `auto` to let the DMG library distribute all icons, including files from `DMG_FILES`. For an intentional layout, set it to `manual` and provide `DMG_ICON_POSITIONS`, for example `"MyApp.app=150,180;Applications=390,180;README.txt=270,300"`. Coordinates are the icon centres in Finder window pixels. + ## Troubleshooting ### "App is damaged and can't be opened" diff --git a/v3/internal/commands/build_assets/darwin/Taskfile.yml b/v3/internal/commands/build_assets/darwin/Taskfile.yml index e3ee1eeb535..ef5fa7c76f5 100644 --- a/v3/internal/commands/build_assets/darwin/Taskfile.yml +++ b/v3/internal/commands/build_assets/darwin/Taskfile.yml @@ -143,13 +143,17 @@ tasks: --background "{{.DMG_BACKGROUND}}" --volume-icon "{{.DMG_VOLUME_ICON}}" --file-icon "{{.DMG_FILE_ICON}}" + --icon-layout "{{.DMG_ICON_LAYOUT}}" --window-width "{{.DMG_WINDOW_WIDTH}}" --window-height "{{.DMG_WINDOW_HEIGHT}}" {{if .DMG_FILES}}--files "{{.DMG_FILES}}"{{end}} + {{if .DMG_ICON_POSITIONS}}--icon-positions "{{.DMG_ICON_POSITIONS}}"{{end}} vars: DMG_BACKGROUND: '{{.DMG_BACKGROUND | default "build/darwin/dmg-background.png"}}' DMG_VOLUME_ICON: '{{.DMG_VOLUME_ICON | default "build/darwin/icons.icns"}}' DMG_FILE_ICON: '{{.DMG_FILE_ICON | default "build/darwin/dmg-file-icon.icns"}}' + DMG_ICON_LAYOUT: '{{.DMG_ICON_LAYOUT | default "auto"}}' + DMG_ICON_POSITIONS: '{{.DMG_ICON_POSITIONS | default ""}}' DMG_WINDOW_WIDTH: '{{.DMG_WINDOW_WIDTH | default "540"}}' DMG_WINDOW_HEIGHT: '{{.DMG_WINDOW_HEIGHT | default "380"}}' DMG_FILES: '{{.DMG_FILES | default ""}}' diff --git a/v3/internal/commands/tool_package.go b/v3/internal/commands/tool_package.go index 79f1f5de0fa..83006652baa 100644 --- a/v3/internal/commands/tool_package.go +++ b/v3/internal/commands/tool_package.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "github.com/leaanthony/dmg/dmg" @@ -112,16 +113,6 @@ func buildDMGOptions(options *flags.ToolPackage, appPath, dmgPath string) (dmg.O opts.Window = dmg.WindowConfig{X: 100, Y: 100, Width: windowWidth, Height: windowHeight} opts.Icon = dmg.IconConfig{Size: 96, TextSize: 12, GridSpace: 100} opts.Files[filepath.Base(appPath)] = appPath - // Finder renders the stored Y from the top of its content area, below a - // small title-bar offset. Subtract it so the default row is visibly centred. - iconY := windowHeight/2 - 26 - if iconY < 0 { - iconY = windowHeight / 2 - } - opts.IconPositions = map[string]dmg.IconPosition{ - filepath.Base(appPath): {X: windowWidth * 28 / 100, Y: iconY}, - "Applications": {X: windowWidth * 72 / 100, Y: iconY}, - } if options.BackgroundImage != "" { opts.Background = &dmg.BackgroundConfig{File: options.BackgroundImage} } @@ -134,9 +125,86 @@ func buildDMGOptions(options *flags.ToolPackage, appPath, dmgPath string) (dmg.O if err := addDMGFiles(&opts, options.DmgFiles); err != nil { return dmg.Options{}, err } + if err := applyDMGIconLayout(&opts, options.DmgIconLayout, options.DmgIconPositions); err != nil { + return dmg.Options{}, err + } return opts, nil } +// applyDMGIconLayout either delegates placement to the DMG library or parses +// explicit Finder-window pixel centres supplied as name=x,y;name=x,y. +func applyDMGIconLayout(opts *dmg.Options, layout, positions string) error { + switch strings.ToLower(strings.TrimSpace(layout)) { + case "", "auto": + if strings.TrimSpace(positions) != "" { + return fmt.Errorf("DMG icon positions require manual icon layout") + } + opts.IconPositions = nil + return nil + case "manual": + iconPositions, err := parseDMGIconPositions(positions) + if err != nil { + return err + } + for name := range iconPositions { + if name != "Applications" { + if _, ok := opts.Files[name]; !ok { + return fmt.Errorf("DMG icon position references unknown item %q", name) + } + } + } + for name := range opts.Files { + if _, ok := iconPositions[name]; !ok { + return fmt.Errorf("manual DMG icon layout is missing a position for %q", name) + } + } + if opts.AddApplicationsSymlink { + if _, ok := iconPositions["Applications"]; !ok { + return fmt.Errorf("manual DMG icon layout is missing a position for Applications") + } + } + opts.IconPositions = iconPositions + return nil + default: + return fmt.Errorf("invalid DMG icon layout %q: expected auto or manual", layout) + } +} + +func parseDMGIconPositions(value string) (map[string]dmg.IconPosition, error) { + if strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("manual DMG icon layout requires icon positions") + } + + positions := make(map[string]dmg.IconPosition) + for _, item := range strings.Split(value, ";") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + name, coordinates, ok := strings.Cut(item, "=") + if !ok || strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("invalid DMG icon position %q: expected name=x,y", item) + } + xy := strings.Split(coordinates, ",") + if len(xy) != 2 { + return nil, fmt.Errorf("invalid DMG icon position %q: expected name=x,y", item) + } + x, err := strconv.Atoi(strings.TrimSpace(xy[0])) + if err != nil { + return nil, fmt.Errorf("invalid DMG icon X coordinate in %q: %w", item, err) + } + y, err := strconv.Atoi(strings.TrimSpace(xy[1])) + if err != nil { + return nil, fmt.Errorf("invalid DMG icon Y coordinate in %q: %w", item, err) + } + positions[strings.TrimSpace(name)] = dmg.IconPosition{X: x, Y: y} + } + if len(positions) == 0 { + return nil, fmt.Errorf("manual DMG icon layout requires icon positions") + } + return positions, nil +} + // addDMGFiles adds optional installer resources configured in a Taskfile. The // deliberately small name=path syntax keeps this low-level packaging command // useful without introducing another project configuration format. diff --git a/v3/internal/commands/tool_package_test.go b/v3/internal/commands/tool_package_test.go index 1264776c790..37f7ba8109a 100644 --- a/v3/internal/commands/tool_package_test.go +++ b/v3/internal/commands/tool_package_test.go @@ -41,13 +41,15 @@ func TestBuildDMGOptions(t *testing.T) { } opts, err := buildDMGOptions(&flags.ToolPackage{ - ExecutableName: "Example", - BackgroundImage: "background.png", - DmgVolumeIcon: "volume.icns", - DmgFileIcon: "file.icns", - DmgFiles: "install.command=" + resource, - DmgWindowWidth: 700, - DmgWindowHeight: 500, + ExecutableName: "Example", + BackgroundImage: "background.png", + DmgVolumeIcon: "volume.icns", + DmgFileIcon: "file.icns", + DmgFiles: "install.command=" + resource, + DmgIconLayout: "manual", + DmgIconPositions: "Example.app=196,224;Applications=504,224;install.command=350,300", + DmgWindowWidth: 700, + DmgWindowHeight: 500, }, appPath, filepath.Join(dir, "Example.dmg")) if err != nil { t.Fatalf("buildDMGOptions() error = %v", err) @@ -74,6 +76,9 @@ func TestBuildDMGOptions(t *testing.T) { if got := opts.IconPositions["Applications"]; got != (dmg.IconPosition{X: 504, Y: 224}) { t.Errorf("Applications icon position = %#v, want {504 224}", got) } + if got := opts.IconPositions["install.command"]; got != (dmg.IconPosition{X: 350, Y: 300}) { + t.Errorf("installer icon position = %#v, want {350 300}", got) + } } func TestBuildDMGOptionsDefaults(t *testing.T) { @@ -84,11 +89,37 @@ func TestBuildDMGOptionsDefaults(t *testing.T) { if opts.Window.Width != 540 || opts.Window.Height != 380 { t.Errorf("Window = %#v, want 540x380", opts.Window) } - if got := opts.IconPositions["Example.app"]; got != (dmg.IconPosition{X: 151, Y: 164}) { - t.Errorf("app icon position = %#v, want {151 164}", got) + if opts.IconPositions != nil { + t.Errorf("IconPositions = %#v, want nil for auto layout", opts.IconPositions) + } +} + +func TestBuildDMGOptionsRejectsInvalidIconLayout(t *testing.T) { + tests := []struct { + name string + layout string + positions string + errMsg string + }{ + {name: "unknown layout", layout: "grid", errMsg: "expected auto or manual"}, + {name: "positions without manual layout", layout: "auto", positions: "Example.app=150,180", errMsg: "require manual"}, + {name: "manual without positions", layout: "manual", errMsg: "requires icon positions"}, + {name: "manual missing Applications", layout: "manual", positions: "Example.app=150,180", errMsg: "Applications"}, + {name: "manual unknown item", layout: "manual", positions: "Example.app=150,180;Applications=390,180;README.txt=270,300", errMsg: "unknown item"}, + {name: "malformed coordinates", layout: "manual", positions: "Example.app=invalid", errMsg: "expected name=x,y"}, } - if got := opts.IconPositions["Applications"]; got != (dmg.IconPosition{X: 388, Y: 164}) { - t.Errorf("Applications icon position = %#v, want {388 164}", got) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildDMGOptions(&flags.ToolPackage{ + ExecutableName: "Example", + DmgIconLayout: tt.layout, + DmgIconPositions: tt.positions, + }, "Example.app", "Example.dmg") + if err == nil || !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("buildDMGOptions() error = %v, want error containing %q", err, tt.errMsg) + } + }) } } diff --git a/v3/internal/flags/package.go b/v3/internal/flags/package.go index a31e0e74e0d..8fd620d90f1 100644 --- a/v3/internal/flags/package.go +++ b/v3/internal/flags/package.go @@ -4,15 +4,17 @@ package flags type ToolPackage struct { Common - Format string `name:"format" description:"Package format to generate (deb, rpm, archlinux, dmg)" default:"deb"` - ExecutableName string `name:"name" description:"Name of the executable to package" default:"myapp"` - ConfigPath string `name:"config" description:"Path to the package configuration file" default:""` - Out string `name:"out" description:"Path to the output dir" default:"."` - BackgroundImage string `name:"background" description:"Path to an optional background image for the DMG" default:""` - DmgVolumeIcon string `name:"volume-icon" description:"Path to the icon shown for the mounted DMG volume" default:""` - DmgFileIcon string `name:"file-icon" description:"Path to the icon shown for the DMG file in Finder" default:""` - DmgFiles string `name:"files" description:"Additional DMG files as name=path pairs separated by commas" default:""` - DmgWindowWidth int `name:"window-width" description:"DMG Finder window width in pixels" default:"540"` - DmgWindowHeight int `name:"window-height" description:"DMG Finder window height in pixels" default:"380"` - CreateDMG bool `name:"create-dmg" description:"Create a DMG file (macOS only)" default:"false"` + Format string `name:"format" description:"Package format to generate (deb, rpm, archlinux, dmg)" default:"deb"` + ExecutableName string `name:"name" description:"Name of the executable to package" default:"myapp"` + ConfigPath string `name:"config" description:"Path to the package configuration file" default:""` + Out string `name:"out" description:"Path to the output dir" default:"."` + BackgroundImage string `name:"background" description:"Path to an optional background image for the DMG" default:""` + DmgVolumeIcon string `name:"volume-icon" description:"Path to the icon shown for the mounted DMG volume" default:""` + DmgFileIcon string `name:"file-icon" description:"Path to the icon shown for the DMG file in Finder" default:""` + DmgFiles string `name:"files" description:"Additional DMG files as name=path pairs separated by commas" default:""` + DmgIconLayout string `name:"icon-layout" description:"DMG icon layout: auto or manual" default:"auto"` + DmgIconPositions string `name:"icon-positions" description:"Manual DMG icon positions as name=x,y pairs separated by semicolons" default:""` + DmgWindowWidth int `name:"window-width" description:"DMG Finder window width in pixels" default:"540"` + DmgWindowHeight int `name:"window-height" description:"DMG Finder window height in pixels" default:"380"` + CreateDMG bool `name:"create-dmg" description:"Create a DMG file (macOS only)" default:"false"` } From b642b11c3244f4c1ed087d939cfa26b7a6f93a67 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sat, 1 Aug 2026 11:49:54 +1000 Subject: [PATCH 2/2] test(v3): cover DMG names with spaces --- v3/internal/commands/tool_package_test.go | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/v3/internal/commands/tool_package_test.go b/v3/internal/commands/tool_package_test.go index 37f7ba8109a..c7cacf888e1 100644 --- a/v3/internal/commands/tool_package_test.go +++ b/v3/internal/commands/tool_package_test.go @@ -94,6 +94,32 @@ func TestBuildDMGOptionsDefaults(t *testing.T) { } } +func TestBuildDMGOptionsSupportsNamesWithSpaces(t *testing.T) { + dir := t.TempDir() + appPath := filepath.Join(dir, "My App.app") + readmePath := filepath.Join(dir, "Read Me.txt") + if err := os.WriteFile(readmePath, []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + + opts, err := buildDMGOptions(&flags.ToolPackage{ + ExecutableName: "My App", + DmgFiles: "Read Me.txt=" + readmePath, + DmgIconLayout: "manual", + DmgIconPositions: "My App.app=150,180;Applications=390,180;Read Me.txt=270,300", + }, appPath, filepath.Join(dir, "My App.dmg")) + if err != nil { + t.Fatalf("buildDMGOptions() error = %v", err) + } + + if got := opts.IconPositions["My App.app"]; got != (dmg.IconPosition{X: 150, Y: 180}) { + t.Errorf("app icon position = %#v, want {150 180}", got) + } + if got := opts.IconPositions["Read Me.txt"]; got != (dmg.IconPosition{X: 270, Y: 300}) { + t.Errorf("extra file icon position = %#v, want {270 300}", got) + } +} + func TestBuildDMGOptionsRejectsInvalidIconLayout(t *testing.T) { tests := []struct { name string