mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
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:
1 parent
1dfd8bf46b
commit
b96926a6bb
3 files changed
+216
-1
No files matched your search
@@ -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.
@@ -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)")
|
||||||
Reference in new issue
Block a user