diff --git a/packaging/macos/build-signed-dmg.sh b/packaging/macos/build-signed-dmg.sh index e3691e65..2e5f8956 100755 --- a/packaging/macos/build-signed-dmg.sh +++ b/packaging/macos/build-signed-dmg.sh @@ -208,8 +208,92 @@ STAGE="$(mktemp -d)" ditto "$APP" "$STAGE/KST4Contest.app" ln -s /Applications "$STAGE/Applications" -hdiutil create -volname "KST4Contest" -srcfolder "$STAGE" \ - -ov -format UDZO -quiet "$DMG" +# 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 <&2 + rm -f "$DMG" + hdiutil create -volname "$VOLUME_NAME" -srcfolder "$STAGE" \ + -ov -format UDZO -quiet "$DMG" +fi rm -rf "$STAGE" [ -f "$DMG" ] || { echo "hdiutil produced no DMG" >&2; exit 1; } diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff new file mode 100644 index 00000000..ddbe52e6 Binary files /dev/null and b/packaging/macos/dmg/background.tiff differ diff --git a/packaging/macos/dmg/render-background.swift b/packaging/macos/dmg/render-background.swift new file mode 100644 index 00000000..c67d9565 --- /dev/null +++ b/packaging/macos/dmg/render-background.swift @@ -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)")