|
| 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 | +} |
0 commit comments