Add styled DMG with arrow pointing to Applications (#5445)

* Add styled DMG with arrow pointing to Applications

- Add create-dmg as a bazel dependency for creating styled DMG installers
- Add background image with arrow pointing from app to Applications
- Update mac_build_handler to use create-dmg for styled DMG creation
- Update CI workflow to use the new DMG creation process

The DMG now shows a visual arrow guiding users to drag the app
to the Applications folder.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add --skip-jenkins flag for headless CI environment

The AppleScript that positions icons times out in CI environments
without a GUI session. Using --skip-jenkins skips the Finder styling
while still including the background image and Applications link.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Switch from create-dmg to dmgbuild for CI compatibility

dmgbuild generates .DS_Store files programmatically without needing
AppleScript or Finder access, making it work in headless CI environments.

- Remove create-dmg bazel dependency (relied on AppleScript)
- Use dmgbuild Python tool instead (pip install dmgbuild)
- Generates proper icon positions and background without GUI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix DMG styling: lighter background, adjust icon positions

- Use light gray background so file/folder names are readable
- Move arrow up to align with icon centers
- Move Applications link further right (500 -> 520) to clear the arrow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix arrow: move left, connect head to shaft properly

- Arrow now starts at x=200 (closer to app icon)
- Arrow ends at x=460 (further from Applications at x=520)
- Head and shaft now overlap for proper connection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 13:06:37 -08:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 56312414ec
commit 10933d3c8b
3 changed files with 84 additions and 44 deletions
+7
View File
@@ -263,6 +263,12 @@ jobs:
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
# Install dmgbuild (creates .DS_Store programmatically, no AppleScript needed)
pip3 install dmgbuild
# Background image for styled DMG
BACKGROUND_PATH="$(pwd)/ci/mac/dmg/background.png"
# Use commit count for automatic incrementing versions (e.g., 1.0.9548)
BUILD_NUMBER=$(git rev-list --count HEAD)
VERSION="1.0.${BUILD_NUMBER}"
@@ -271,6 +277,7 @@ jobs:
"/tmp/eagle0/eagle0MAC/eagle0.app" \
"$VERSION" \
"$BUILD_NUMBER" \
"$BACKGROUND_PATH" \
"$SPARKLE_PRIVATE_KEY_PATH"
rm "$SPARKLE_PRIVATE_KEY_PATH"
Binary file not shown.
@@ -51,47 +51,72 @@ type Enclosure struct {
EdSig string `xml:"sparkle:edSignature,attr"`
}
func createDMG(appPath string, dmgPath string, volumeName string) error {
// Create a temporary directory for the DMG contents
tmpDir, err := os.MkdirTemp("", "dmg-contents-")
if err != nil {
return fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
// Copy the app to the temp directory
appName := filepath.Base(appPath)
destApp := filepath.Join(tmpDir, appName)
log.Printf("Copying %s to %s", appPath, destApp)
// Use ditto to preserve all metadata, permissions, and symlinks
cmd := exec.Command("ditto", appPath, destApp)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to copy app: %s: %w", string(output), err)
}
// Create an alias to /Applications
applicationsAlias := filepath.Join(tmpDir, "Applications")
cmd = exec.Command("ln", "-s", "/Applications", applicationsAlias)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create Applications symlink: %s: %w", string(output), err)
}
func createDMG(appPath string, dmgPath string, volumeName string, backgroundPath string) error {
// Remove any existing DMG
os.Remove(dmgPath)
// Create the DMG using hdiutil
// -volname: Name shown when mounted
// -srcfolder: Source directory
// -ov: Overwrite existing
// -format UDZO: Compressed DMG (read-only)
log.Printf("Creating DMG: %s", dmgPath)
cmd = exec.Command("hdiutil", "create",
"-volname", volumeName,
"-srcfolder", tmpDir,
"-ov",
"-format", "UDZO",
dmgPath)
log.Printf("Creating styled DMG: %s", dmgPath)
// Create a temporary dmgbuild settings file
settingsFile, err := os.CreateTemp("", "dmgbuild-settings-*.py")
if err != nil {
return fmt.Errorf("failed to create settings file: %w", err)
}
defer os.Remove(settingsFile.Name())
appName := filepath.Base(appPath)
// dmgbuild settings - generates .DS_Store programmatically without AppleScript
settings := fmt.Sprintf(`
# dmgbuild settings for Eagle0
import os
# Volume name
volume_name = %q
# Output format
format = 'UDZO'
# Volume size (None = auto)
size = None
# Files to include
files = [
(%q, %q),
]
# Symlinks
symlinks = {
'Applications': '/Applications',
}
# Background image
background = %q
# Window settings
icon_size = 128
icon_locations = {
%q: (140, 180),
'Applications': (520, 180),
}
# Window position and size
window_rect = ((100, 100), (660, 400))
# Hide standard folders
hide_extensions = ['app']
`, volumeName, appPath, appName, backgroundPath, appName)
if _, err := settingsFile.WriteString(settings); err != nil {
return fmt.Errorf("failed to write settings: %w", err)
}
settingsFile.Close()
// Run dmgbuild
cmd := exec.Command("dmgbuild", "-s", settingsFile.Name(), volumeName, dmgPath)
cmd.Env = append(os.Environ(), "PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:"+os.Getenv("HOME")+"/.local/bin")
log.Printf("Running: dmgbuild -s %s %s %s", settingsFile.Name(), volumeName, dmgPath)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create DMG: %s: %w", string(output), err)
}
@@ -198,34 +223,39 @@ func uploadAppcast(bb aws.BucketBasics, appcast *Appcast) error {
}
func main() {
if len(os.Args) < 4 {
fmt.Println("Usage: mac_build_handler <app_path> <version> <build_number> [sparkle_private_key_path]")
if len(os.Args) < 5 {
fmt.Println("Usage: mac_build_handler <app_path> <version> <build_number> <background_path> [sparkle_private_key_path]")
os.Exit(1)
}
appPath := os.Args[1]
version := os.Args[2]
buildNumber := os.Args[3]
backgroundPath := os.Args[4]
privateKeyPath := ""
if len(os.Args) >= 5 {
privateKeyPath = os.Args[4]
if len(os.Args) >= 6 {
privateKeyPath = os.Args[5]
}
if _, err := os.Stat(appPath); os.IsNotExist(err) {
log.Fatalf("App not found: %s", appPath)
}
if _, err := os.Stat(backgroundPath); os.IsNotExist(err) {
log.Fatalf("Background image not found: %s", backgroundPath)
}
bb, err := aws.NewBucketBasics()
if err != nil {
log.Fatal(err)
}
// Create DMG of the app
// Create DMG of the app using dmgbuild (generates .DS_Store programmatically)
dmgFileName := fmt.Sprintf("eagle0-%s.dmg", version)
dmgPath := filepath.Join("/tmp", dmgFileName)
log.Printf("Creating DMG: %s", dmgPath)
if err := createDMG(appPath, dmgPath, "Eagle0"); err != nil {
if err := createDMG(appPath, dmgPath, "Eagle0", backgroundPath); err != nil {
log.Fatalf("Failed to create DMG: %v", err)
}