Skip to content

Commit eb74cb6

Browse files
mannasdevclaude
andcommitted
Style the installer window instead of shipping Finder's default
The mounted DMG showed a bare Finder window: default size, visible toolbar, unpositioned icons. Now it opens at 640x400 with the toolbar hidden, 128px icons placed either side of a drawn arrow, and a background that names the one action there is to take. The art is rendered in Swift from the hudson.pen tokens rather than exported from a design tool, so the installer cannot drift from the app it installs. Emitted at 1x and 2x and combined with `tiffutil -cathidpicheck`, so Retina displays get real pixels rather than an upscale. Icon coordinates in the AppleScript and the arrow endpoints in the renderer are the same numbers and have to stay that way. Two failure modes found while building it: - Finder styling needs a GUI session, and silently does nothing in a background/CI context. Non-fatal by design: a plain installer beats a failed release. - Detach returns before the kernel releases the device, so converting immediately loses a race and fails "Resource temporarily unavailable". Retried, and the compressed image is now staged and moved into place at the end — an earlier version deleted the old DMG up front and left nothing behind when a later step failed. zlib-level=9 on the convert also took the download from 3.9MB to 3.5MB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1d0a826 commit eb74cb6

3 files changed

Lines changed: 255 additions & 2 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ dist/
1818
# vs scripts/ case, on case-sensitive filesystems too.
1919
hudson-secrets.env
2020
*.secret.env
21+
22+
# Installer background art — regenerated by Scripts/make-dmg-background.swift
23+
# on every make-dmg.sh run, so the PNGs are build output, not source.
24+
Design/DMG/

Scripts/make-dmg-background.swift

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#!/usr/bin/env swift
2+
//
3+
// Renders the installer window's background art into Design/DMG/.
4+
//
5+
// Run via Scripts/make-dmg.sh, which then hands the result to Finder as the
6+
// mounted volume's background picture. Drawn in code rather than exported from
7+
// a design tool so the palette below stays the single source of truth: these
8+
// are the same tokens as hudson.pen, the SwiftUI Palette, and the website's
9+
// global.css, so the installer cannot quietly drift away from the app it
10+
// installs.
11+
//
12+
// Emits a 1x and a 2x PNG; make-dmg.sh combines them into one HiDPI TIFF with
13+
// `tiffutil -cathidpicheck`, which is what keeps the art sharp on a Retina
14+
// display instead of visibly upscaled.
15+
//
16+
// Usage: swift Scripts/make-dmg-background.swift <out-dir>
17+
18+
import AppKit
19+
import Foundation
20+
21+
// MARK: - Design tokens (hudson.pen)
22+
23+
let bgApp = NSColor(srgbRed: 0x1C / 255, green: 0x1C / 255, blue: 0x1A / 255, alpha: 1)
24+
let inkSecondary = NSColor(srgbRed: 0xA2 / 255, green: 0x9E / 255, blue: 0x95 / 255, alpha: 1)
25+
let inkTertiary = NSColor(srgbRed: 0x6E / 255, green: 0x6B / 255, blue: 0x64 / 255, alpha: 1)
26+
let accent = NSColor(srgbRed: 0x8F / 255, green: 0xB5 / 255, blue: 0xA5 / 255, alpha: 1)
27+
28+
/// Window content size in points. Icon positions in make-dmg.sh's AppleScript
29+
/// are expressed in this same coordinate space, so the two must agree: change
30+
/// one and the arrow stops pointing at the folder.
31+
let size = NSSize(width: 640, height: 400)
32+
33+
/// Where Finder places the two icons, in AppleScript's coordinate space, which
34+
/// measures y downward from the top of the window.
35+
let appIconCenter = CGPoint(x: 170, y: 190)
36+
let applicationsCenter = CGPoint(x: 470, y: 190)
37+
38+
// MARK: - Drawing
39+
40+
/// Converts an AppleScript icon position (y down from top) into the bottom-up
41+
/// coordinates Core Graphics draws in.
42+
func flipped(_ point: CGPoint) -> CGPoint {
43+
CGPoint(x: point.x, y: size.height - point.y)
44+
}
45+
46+
func drawCenteredText(
47+
_ text: String, at center: CGPoint, font: NSFont, color: NSColor, tracking: CGFloat = 0
48+
) {
49+
let attributes: [NSAttributedString.Key: Any] = [
50+
.font: font, .foregroundColor: color, .kern: tracking,
51+
]
52+
let line = NSAttributedString(string: text, attributes: attributes)
53+
let bounds = line.size()
54+
line.draw(at: NSPoint(x: center.x - bounds.width / 2, y: center.y - bounds.height / 2))
55+
}
56+
57+
/// The arrow between the app and the Applications folder. Drawn as a shallow
58+
/// arc rather than a straight line so it reads as a gesture — the same easing
59+
/// the app's motion system uses — and stops short of both icons so it never
60+
/// crowds them.
61+
func drawArrow() {
62+
let start = flipped(CGPoint(x: appIconCenter.x + 78, y: appIconCenter.y))
63+
let end = flipped(CGPoint(x: applicationsCenter.x - 78, y: applicationsCenter.y))
64+
let lift: CGFloat = 26
65+
let control = CGPoint(x: (start.x + end.x) / 2, y: start.y + lift)
66+
67+
let path = NSBezierPath()
68+
path.move(to: start)
69+
path.curve(to: end, controlPoint1: control, controlPoint2: control)
70+
path.lineWidth = 2
71+
path.lineCapStyle = .round
72+
inkTertiary.setStroke()
73+
path.stroke()
74+
75+
// Arrowhead, aligned to the curve's tangent as it arrives at `end`.
76+
let tangent = CGPoint(x: end.x - control.x, y: end.y - control.y)
77+
let angle = atan2(tangent.y, tangent.x)
78+
let headLength: CGFloat = 11
79+
let spread = CGFloat.pi / 7
80+
81+
let head = NSBezierPath()
82+
head.move(to: end)
83+
head.line(
84+
to: CGPoint(
85+
x: end.x - headLength * cos(angle - spread),
86+
y: end.y - headLength * sin(angle - spread)))
87+
head.move(to: end)
88+
head.line(
89+
to: CGPoint(
90+
x: end.x - headLength * cos(angle + spread),
91+
y: end.y - headLength * sin(angle + spread)))
92+
head.lineWidth = 2
93+
head.lineCapStyle = .round
94+
inkTertiary.setStroke()
95+
head.stroke()
96+
}
97+
98+
/// The Hudson wordmark's wave, matching the site's nav mark.
99+
func drawWave(center: CGPoint, width: CGFloat) {
100+
let height = width * 0.22
101+
let path = NSBezierPath()
102+
let segment = width / 3
103+
104+
path.move(to: CGPoint(x: center.x - width / 2, y: center.y))
105+
for index in 0..<3 {
106+
let x0 = center.x - width / 2 + segment * CGFloat(index)
107+
path.curve(
108+
to: CGPoint(x: x0 + segment, y: center.y),
109+
controlPoint1: CGPoint(x: x0 + segment * 0.37, y: center.y + height),
110+
controlPoint2: CGPoint(x: x0 + segment * 0.63, y: center.y - height))
111+
}
112+
path.lineWidth = 2
113+
path.lineCapStyle = .round
114+
accent.withAlphaComponent(0.55).setStroke()
115+
path.stroke()
116+
}
117+
118+
func renderBackground(scale: CGFloat) -> NSBitmapImageRep {
119+
let pixelWidth = Int(size.width * scale)
120+
let pixelHeight = Int(size.height * scale)
121+
122+
guard
123+
let rep = NSBitmapImageRep(
124+
bitmapDataPlanes: nil, pixelsWide: pixelWidth, pixelsHigh: pixelHeight,
125+
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
126+
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)
127+
else { fatalError("could not allocate a \(pixelWidth)x\(pixelHeight) bitmap") }
128+
rep.size = size
129+
130+
NSGraphicsContext.saveGraphicsState()
131+
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
132+
133+
bgApp.setFill()
134+
NSRect(origin: .zero, size: size).fill()
135+
136+
drawWave(center: CGPoint(x: size.width / 2, y: size.height - 66), width: 44)
137+
138+
drawCenteredText(
139+
"Drag Hudson into Applications",
140+
at: CGPoint(x: size.width / 2, y: size.height - 108),
141+
font: .systemFont(ofSize: 15, weight: .medium),
142+
color: inkSecondary)
143+
144+
drawArrow()
145+
146+
drawCenteredText(
147+
"Free, open source, and entirely on your Mac.",
148+
at: CGPoint(x: size.width / 2, y: 58),
149+
font: .systemFont(ofSize: 12, weight: .regular),
150+
color: inkTertiary)
151+
152+
NSGraphicsContext.restoreGraphicsState()
153+
return rep
154+
}
155+
156+
// MARK: - Entry point
157+
158+
let outputDirectory = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "Design/DMG"
159+
try? FileManager.default.createDirectory(
160+
atPath: outputDirectory, withIntermediateDirectories: true)
161+
162+
for (scale, name) in [(CGFloat(1), "background.png"), (CGFloat(2), "background@2x.png")] {
163+
let rep = renderBackground(scale: scale)
164+
guard let data = rep.representation(using: .png, properties: [:]) else {
165+
fatalError("could not encode \(name)")
166+
}
167+
let path = (outputDirectory as NSString).appendingPathComponent(name)
168+
try data.write(to: URL(fileURLWithPath: path))
169+
print("wrote \(path) (\(rep.pixelsWide)x\(rep.pixelsHigh))")
170+
}

Scripts/make-dmg.sh

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,87 @@ mkdir -p "$STAGE"
5252
cp -R "$APP" "$STAGE/"
5353
ln -s /Applications "$STAGE/Applications" # the classic "drag me →" target
5454

55-
rm -f "$DMG"
56-
hdiutil create -volname "$VOLNAME" -srcfolder "$STAGE" -ov -format UDZO "$DMG" >/dev/null
55+
# Background art, rendered from the hudson.pen tokens. The 1x and 2x PNGs are
56+
# folded into ONE HiDPI TIFF: Finder picks the right representation per display,
57+
# which is what stops the art looking upscaled on a Retina Mac.
58+
echo "Rendering installer background…"
59+
swift Scripts/make-dmg-background.swift Design/DMG
60+
mkdir -p "$STAGE/.background"
61+
tiffutil -cathidpicheck Design/DMG/background.png Design/DMG/background@2x.png \
62+
-out "$STAGE/.background/background.tiff" >/dev/null
63+
64+
# Build a READ-WRITE image first. Finder can only record window geometry, icon
65+
# positions and the background picture into a volume it can write to; the
66+
# compressed read-only image users download is converted from it at the end.
67+
#
68+
# The existing $DMG is deliberately left alone until the new one is complete and
69+
# only then moved into place. An earlier version deleted it up front, and when a
70+
# later step failed the release artifact was simply gone.
71+
RW_DMG="$(dirname "$STAGE")/rw.dmg"
72+
hdiutil create -volname "$VOLNAME" -srcfolder "$STAGE" -ov \
73+
-format UDRW -fs HFS+ "$RW_DMG" >/dev/null
74+
75+
MOUNT_POINT="/Volumes/$VOLNAME"
76+
hdiutil attach "$RW_DMG" -nobrowse -noautoopen >/dev/null
77+
78+
# Icon coordinates below MUST match `appIconCenter` / `applicationsCenter` in
79+
# make-dmg-background.swift, or the drawn arrow stops pointing at the folder.
80+
#
81+
# This is the one step that needs a real GUI session: it drives Finder, so it
82+
# fails on a headless CI box and when Terminal lacks Automation permission for
83+
# Finder. Treated as non-fatal on purpose — a plain-looking installer is a much
84+
# better outcome than a failed release build.
85+
echo "Laying out the installer window…"
86+
if osascript <<APPLESCRIPT >/dev/null 2>&1
87+
tell application "Finder"
88+
tell disk "$VOLNAME"
89+
open
90+
set current view of container window to icon view
91+
set toolbar visible of container window to false
92+
set statusbar visible of container window to false
93+
set the bounds of container window to {200, 120, 840, 520}
94+
set opts to the icon view options of container window
95+
set arrangement of opts to not arranged
96+
set icon size of opts to 128
97+
set text size of opts to 13
98+
set background picture of opts to file ".background:background.tiff"
99+
set position of item "Hudson.app" of container window to {170, 190}
100+
set position of item "Applications" of container window to {470, 190}
101+
close
102+
open
103+
update without registering applications
104+
delay 2
105+
end tell
106+
end tell
107+
APPLESCRIPT
108+
then
109+
echo " window styled"
110+
else
111+
echo " WARN: Finder styling skipped (needs a GUI session + Automation permission)." >&2
112+
echo " The DMG is still valid, just with the default window." >&2
113+
fi
114+
115+
# Let Finder's .DS_Store write land before the volume goes away.
116+
sync
117+
hdiutil detach "$MOUNT_POINT" >/dev/null 2>&1 || hdiutil detach "$MOUNT_POINT" -force >/dev/null 2>&1
118+
119+
# Detaching returns before the kernel has finished releasing the device, so an
120+
# immediate convert loses a race with it and fails "Resource temporarily
121+
# unavailable". Retry briefly rather than failing the build.
122+
STAGED_DMG="$(dirname "$STAGE")/staged.dmg"
123+
for attempt in 1 2 3 4 5 6 7 8 9 10; do
124+
if hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 \
125+
-o "$STAGED_DMG" -ov >/dev/null 2>&1; then
126+
break
127+
fi
128+
if [[ "$attempt" == 10 ]]; then
129+
echo "ERROR: hdiutil convert kept failing; $DMG left untouched." >&2
130+
exit 1
131+
fi
132+
sleep 2
133+
done
134+
135+
mv -f "$STAGED_DMG" "$DMG"
57136
rm -rf "$(dirname "$STAGE")"
58137
echo "Built $DMG"
59138

0 commit comments

Comments
 (0)