Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/src/content/docs/guides/build/macos.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Comment on lines +125 to +128
DMG_WINDOW_WIDTH: 540
DMG_WINDOW_HEIGHT: 380
DMG_FILES: "Install.command=build/darwin/Install.command,README.txt=README.md"
Expand Down Expand Up @@ -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.
</Aside>

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"
Expand Down
4 changes: 4 additions & 0 deletions v3/internal/commands/build_assets/darwin/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""}}'
Expand Down
88 changes: 78 additions & 10 deletions v3/internal/commands/tool_package.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"

"github.com/leaanthony/dmg/dmg"
Expand Down Expand Up @@ -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}
}
Expand All @@ -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)
}
}
}
Comment on lines +149 to +155
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}
Comment on lines +188 to +200
}
if len(positions) == 0 {
return nil, fmt.Errorf("manual DMG icon layout requires icon positions")
}
return positions, nil
}
Comment on lines +173 to +206

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject duplicate names in manual icon positions.

parseDMGIconPositions silently overwrites an earlier entry when the same name appears twice in the ;-separated list. addDMGFiles, in the same file, explicitly rejects a duplicate name with "DMG file %q conflicts with an existing entry". Apply the same fail-loud pattern here so a typo does not silently discard a position.

🛠️ Proposed fix to reject duplicate position names
 		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)
 		}
+		name = strings.TrimSpace(name)
+		if _, exists := positions[name]; exists {
+			return nil, fmt.Errorf("duplicate DMG icon position for %q", name)
+		}
 		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}
+		positions[name] = dmg.IconPosition{X: x, Y: y}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
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)
}
name = strings.TrimSpace(name)
if _, exists := positions[name]; exists {
return nil, fmt.Errorf("duplicate DMG icon position for %q", name)
}
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[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
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v3/internal/commands/tool_package.go` around lines 173 - 206, Update
parseDMGIconPositions to detect when the trimmed icon name already exists in
positions before assigning it, and return an error matching the existing
duplicate-entry pattern instead of overwriting the prior position. Preserve the
current coordinate validation and successful parsing behavior for unique names.


// 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.
Expand Down
79 changes: 68 additions & 11 deletions v3/internal/commands/tool_package_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -84,11 +89,63 @@ 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 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
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)
}
})
}
}

Expand Down
24 changes: 13 additions & 11 deletions v3/internal/flags/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Loading