Show a drag-to-install layout in the macOS DMG

The DMG window now has a background with an arrow from the app to
the Applications folder, in the KST4Contest colours. Finder applies
the layout through AppleScript; if that fails, e.g. on a headless
runner, the script warns and builds the plain DMG as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 committed 2026-09-22 23:19:55 +02:00
1 parent 1dfd8bf46b
commit b96926a6bb
3 files changed
+216 -1

No files matched your search

+85 -1
View File
@@ -208,8 +208,92 @@ STAGE="$(mktemp -d)"
ditto "$APP" "$STAGE/KST4Contest.app" ditto "$APP" "$STAGE/KST4Contest.app"
ln -s /Applications "$STAGE/Applications" ln -s /Applications "$STAGE/Applications"
hdiutil create -volname "KST4Contest" -srcfolder "$STAGE" \ # The installer window shows a background with an arrow from the app to the
# Applications folder. Finder only stores that layout (.DS_Store) when it has
# arranged a mounted, writable image itself, so this drives Finder through
# AppleScript. That can fail, e.g. without automation permission for Finder or
# on a headless CI runner; the DMG is then built without the layout and only a
# warning is printed. Window size and icon positions must match
# packaging/macos/dmg/render-background.swift.
DMG_BACKGROUND="packaging/macos/dmg/background.tiff"
VOLUME_NAME="KST4Contest"
build_dmg_with_layout() {
local mount_point="/Volumes/$VOLUME_NAME"
local rw_dir rw_dmg size_mb
if [ ! -f "$DMG_BACKGROUND" ]; then
echo " $DMG_BACKGROUND is missing" >&2
return 1
fi
# Finder addresses the disk by its name; a second mounted volume of the same
# name would get another name and break the stored background reference.
if [ -e "$mount_point" ]; then
echo " $mount_point is already mounted, eject it first" >&2
return 1
fi
rw_dir="$(mktemp -d)"
rw_dmg="$rw_dir/KST4Contest-rw.dmg"
# Some headroom for the background picture and the .DS_Store Finder writes.
size_mb=$(( $(du -sm "$STAGE" | cut -f1) + 20 ))
hdiutil create -volname "$VOLUME_NAME" -srcfolder "$STAGE" -fs HFS+ \
-format UDRW -size "${size_mb}m" -ov -quiet "$rw_dmg" || { rm -rf "$rw_dir"; return 1; }
hdiutil attach "$rw_dmg" -readwrite -noverify -noautoopen -quiet || { rm -rf "$rw_dir"; return 1; }
local layout_ok=0
mkdir -p "$mount_point/.background" \
&& cp "$DMG_BACKGROUND" "$mount_point/.background/background.tiff" \
&& osascript <<APPLESCRIPT || layout_ok=1
tell application "Finder"
tell disk "$VOLUME_NAME"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {200, 120, 840, 520}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 128
set text size of viewOptions to 13
set background picture of viewOptions to file ".background:background.tiff"
set position of item "KST4Contest.app" of container window to {170, 180}
set position of item "Applications" of container window to {470, 180}
close
open
update without registering applications
delay 2
close
end tell
end tell
APPLESCRIPT
# Give Finder time to write .DS_Store before the image is detached.
sync
sleep 2
rm -rf "$mount_point/.fseventsd"
hdiutil detach "$mount_point" -quiet \
|| hdiutil detach "$mount_point" -force -quiet \
|| { rm -rf "$rw_dir"; return 1; }
if [ "$layout_ok" -ne 0 ]; then
rm -rf "$rw_dir"
return 1
fi
hdiutil convert "$rw_dmg" -format UDZO -ov -quiet -o "$DMG" || { rm -rf "$rw_dir"; return 1; }
rm -rf "$rw_dir"
}
if build_dmg_with_layout; then
echo " installer window layout applied"
else
echo "WARNING: could not apply the DMG window layout, building a plain DMG instead" >&2
rm -f "$DMG"
hdiutil create -volname "$VOLUME_NAME" -srcfolder "$STAGE" \
-ov -format UDZO -quiet "$DMG" -ov -format UDZO -quiet "$DMG"
fi
rm -rf "$STAGE" rm -rf "$STAGE"
[ -f "$DMG" ] || { echo "hdiutil produced no DMG" >&2; exit 1; } [ -f "$DMG" ] || { echo "hdiutil produced no DMG" >&2; exit 1; }
Binary file not shown.
+131
View File
@@ -0,0 +1,131 @@
// Renders the background of the macOS installer DMG window.
//
// Run from the repository root after changing the design; only the TIFF is kept:
// TMP="$(mktemp -d)"
// swift packaging/macos/dmg/render-background.swift "$TMP"
// tiffutil -cathidpicheck "$TMP/background.png" "$TMP/background@2x.png" \
// -out packaging/macos/dmg/background.tiff
//
// The layout (window size, icon positions) must match build-signed-dmg.sh.
// Colors follow the green KST4Contest website theme and the application icon.
import AppKit
let windowWidth: CGFloat = 640
let windowHeight: CGFloat = 400
// Icon centers in Finder coordinates (origin top left), same values as in the build script.
let appIconCenter = CGPoint(x: 170, y: 180)
let applicationsIconCenter = CGPoint(x: 470, y: 180)
let iconSize: CGFloat = 128
func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
CGColor(srgbRed: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255,
alpha: alpha)
}
func render(scale: CGFloat, to path: String) {
let pixelWidth = Int(windowWidth * scale)
let pixelHeight = Int(windowHeight * scale)
guard let ctx = CGContext(data: nil, width: pixelWidth, height: pixelHeight,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
fatalError("Could not create drawing context")
}
// Draw in Finder coordinates: origin top left, y pointing down.
ctx.scaleBy(x: scale, y: scale)
ctx.translateBy(x: 0, y: windowHeight)
ctx.scaleBy(x: 1, y: -1)
let space = CGColorSpace(name: CGColorSpace.sRGB)!
// Background: dark green gradient of the website theme.
let background = CGGradient(colorsSpace: space,
colors: [color(0x0b140f), color(0x050806)] as CFArray,
locations: [0, 1])!
ctx.drawLinearGradient(background, start: CGPoint(x: 0, y: 0),
end: CGPoint(x: 0, y: windowHeight), options: [])
// Soft accent glow behind the arrow.
let glow = CGGradient(colorsSpace: space,
colors: [color(0x54d63d, 0.16), color(0x54d63d, 0)] as CFArray,
locations: [0, 1])!
let middle = CGPoint(x: (appIconCenter.x + applicationsIconCenter.x) / 2, y: appIconCenter.y)
ctx.drawRadialGradient(glow, startCenter: middle, startRadius: 0,
endCenter: middle, endRadius: 260, options: [])
// Radio waves like in the icon, above the arrow.
ctx.setLineCap(.round)
ctx.setLineWidth(4)
let waveCenter = CGPoint(x: middle.x, y: middle.y - 18)
for (index, radius) in [CGFloat(14), 26].enumerated() {
ctx.setStrokeColor(color(0x8aef64, index == 0 ? 0.55 : 0.3))
ctx.addArc(center: waveCenter, radius: radius, startAngle: -.pi / 4 - .pi / 2,
endAngle: .pi / 4 - .pi / 2, clockwise: false)
ctx.strokePath()
}
// Arrow from the app to the Applications folder.
let arrowStart = appIconCenter.x + iconSize / 2 + 22
let arrowEnd = applicationsIconCenter.x - iconSize / 2 - 22
let arrowY = appIconCenter.y
let headLength: CGFloat = 22
let headHalfWidth: CGFloat = 16
ctx.saveGState()
ctx.setShadow(offset: .zero, blur: 12, color: color(0x54d63d, 0.6))
let arrowPath = CGMutablePath()
arrowPath.move(to: CGPoint(x: arrowStart, y: arrowY))
arrowPath.addLine(to: CGPoint(x: arrowEnd - headLength + 2, y: arrowY))
ctx.addPath(arrowPath)
ctx.setLineWidth(10)
ctx.setStrokeColor(color(0x54d63d))
ctx.strokePath()
let head = CGMutablePath()
head.move(to: CGPoint(x: arrowEnd, y: arrowY))
head.addLine(to: CGPoint(x: arrowEnd - headLength, y: arrowY - headHalfWidth))
head.addLine(to: CGPoint(x: arrowEnd - headLength, y: arrowY + headHalfWidth))
head.closeSubpath()
ctx.addPath(head)
ctx.setFillColor(color(0x54d63d))
ctx.setLineJoin(.round)
ctx.setLineWidth(4)
ctx.setStrokeColor(color(0x54d63d))
ctx.drawPath(using: .fillStroke)
ctx.restoreGState()
// Label plates: Finder draws icon labels black in light mode and white in dark
// mode, and the color cannot be configured. This mid tone keeps both readable
// (contrast about 4.6:1 for white and for black text).
let labelTop = appIconCenter.y + iconSize / 2 + 4
for center in [appIconCenter, applicationsIconCenter] {
let plate = CGRect(x: center.x - 80, y: labelTop, width: 160, height: 26)
ctx.addPath(CGPath(roundedRect: plate, cornerWidth: 13, cornerHeight: 13, transform: nil))
ctx.setFillColor(color(0x5c7d63))
ctx.fillPath()
}
// Thin accent line at the bottom, as used on the website.
ctx.setFillColor(color(0x31b54a))
ctx.fill(CGRect(x: 0, y: windowHeight - 4, width: windowWidth, height: 4))
guard let image = ctx.makeImage() else {
fatalError("Could not render image")
}
let rep = NSBitmapImageRep(cgImage: image)
// 72 dpi for 1x and 144 dpi for 2x, so tiffutil can combine them into one HiDPI TIFF.
rep.size = NSSize(width: windowWidth, height: windowHeight)
guard let data = rep.representation(using: .png, properties: [:]) else {
fatalError("Could not encode PNG")
}
try! data.write(to: URL(fileURLWithPath: path))
}
let outputDirectory = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "."
render(scale: 1, to: outputDirectory + "/background.png")
render(scale: 2, to: outputDirectory + "/background@2x.png")
print("Rendered background.png and background@2x.png into \(outputDirectory)")