diff --git a/Info.plist b/Info.plist
index 62f3b8d..8717b5c 100644
--- a/Info.plist
+++ b/Info.plist
@@ -18,6 +18,8 @@
0.1.0
CFBundleVersion
1
+ CFBundleIconFile
+ AppIcon
LSUIElement
LSMinimumSystemVersion
diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns
new file mode 100644
index 0000000..f3396f0
Binary files /dev/null and b/Resources/AppIcon.icns differ
diff --git a/scripts/build-app.sh b/scripts/build-app.sh
index 1a226f9..bac4075 100755
--- a/scripts/build-app.sh
+++ b/scripts/build-app.sh
@@ -43,6 +43,7 @@ mkdir -p "${APP_BUNDLE}/Contents/Resources"
cp "${BIN_PATH}" "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
chmod +x "${APP_BUNDLE}/Contents/MacOS/Shotdeck"
cp "${ROOT}/Info.plist" "${APP_BUNDLE}/Contents/Info.plist"
+cp "${ROOT}/Resources/AppIcon.icns" "${APP_BUNDLE}/Contents/Resources/AppIcon.icns"
if [[ "${SKIP_SIGN}" -eq 1 ]]; then
echo
diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift
new file mode 100644
index 0000000..439c03e
--- /dev/null
+++ b/scripts/make-icon.swift
@@ -0,0 +1,324 @@
+#!/usr/bin/env swift
+import Foundation
+import CoreGraphics
+import ImageIO
+
+// Programmatic Redline app icon.
+// Draws a 1024×1024 master, writes AppIcon.iconset (16…1024 incl. @2x),
+// and compiles Resources/AppIcon.icns via iconutil.
+//
+// Usage:
+// swift scripts/make-icon.swift
+// swift scripts/make-icon.swift --preview /path/to/icon-512.png
+
+private let masterSize = 1024
+
+private enum Palette {
+ static let charcoalTop = CGColor(srgbRed: 44.0 / 255.0, green: 44.0 / 255.0, blue: 46.0 / 255.0, alpha: 1)
+ static let charcoalBottom = CGColor(srgbRed: 28.0 / 255.0, green: 28.0 / 255.0, blue: 30.0 / 255.0, alpha: 1) // #1C1C1E
+ static let white = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
+ static let redline = CGColor(srgbRed: 229.0 / 255.0, green: 72.0 / 255.0, blue: 63.0 / 255.0, alpha: 1) // #E5483F
+}
+
+private struct IconsetEntry {
+ let filename: String
+ let pixels: Int
+}
+
+private let iconsetEntries: [IconsetEntry] = [
+ IconsetEntry(filename: "icon_16x16.png", pixels: 16),
+ IconsetEntry(filename: "icon_16x16@2x.png", pixels: 32),
+ IconsetEntry(filename: "icon_32x32.png", pixels: 32),
+ IconsetEntry(filename: "icon_32x32@2x.png", pixels: 64),
+ IconsetEntry(filename: "icon_128x128.png", pixels: 128),
+ IconsetEntry(filename: "icon_128x128@2x.png", pixels: 256),
+ IconsetEntry(filename: "icon_256x256.png", pixels: 256),
+ IconsetEntry(filename: "icon_256x256@2x.png", pixels: 512),
+ IconsetEntry(filename: "icon_512x512.png", pixels: 512),
+ IconsetEntry(filename: "icon_512x512@2x.png", pixels: 1024),
+]
+
+// MARK: - Geometry
+
+/// Apple-style continuous-corner rounded square (squircle-like).
+/// Superellipse |x/a|^n + |y/b|^n = 1 with n≈5, inset 1px so antialiased
+/// edge pixels are not clipped by the bitmap.
+private func continuousRoundedSquare(size: CGFloat, exponent n: CGFloat = 5.0, segments: Int = 256) -> CGPath {
+ let inset: CGFloat = 1
+ let a = (size - inset * 2) / 2
+ let cx = size / 2
+ let cy = size / 2
+ let twoOverN = 2 / n
+ let path = CGMutablePath()
+ for i in 0...segments {
+ let theta = CGFloat(i) / CGFloat(segments) * 2 * .pi
+ let ct = cos(theta)
+ let st = sin(theta)
+ let x = cx + (ct < 0 ? -1 : 1) * pow(abs(ct), twoOverN) * a
+ let y = cy + (st < 0 ? -1 : 1) * pow(abs(st), twoOverN) * a
+ if i == 0 {
+ path.move(to: CGPoint(x: x, y: y))
+ } else {
+ path.addLine(to: CGPoint(x: x, y: y))
+ }
+ }
+ path.closeSubpath()
+ return path
+}
+
+// MARK: - Drawing
+
+private func drawMasterIcon(size: Int) -> CGImage {
+ let s = CGFloat(size)
+ let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
+ let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue
+ guard let ctx = CGContext(
+ data: nil,
+ width: size,
+ height: size,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: colorSpace,
+ bitmapInfo: bitmapInfo
+ ) else {
+ fputs("error: failed to create \(size)×\(size) bitmap context\n", stderr)
+ exit(1)
+ }
+
+ ctx.setShouldAntialias(true)
+ ctx.setAllowsAntialiasing(true)
+ ctx.interpolationQuality = .high
+
+ // Flip to top-left origin so "top-to-bottom gradient" is literal.
+ ctx.translateBy(x: 0, y: s)
+ ctx.scaleBy(x: 1, y: -1)
+
+ ctx.clear(CGRect(x: 0, y: 0, width: s, height: s))
+
+ let squircle = continuousRoundedSquare(size: s)
+
+ ctx.saveGState()
+ ctx.addPath(squircle)
+ ctx.clip()
+
+ let gradient = CGGradient(
+ colorsSpace: colorSpace,
+ colors: [Palette.charcoalTop, Palette.charcoalBottom] as CFArray,
+ locations: [0, 1]
+ )!
+ ctx.drawLinearGradient(
+ gradient,
+ start: CGPoint(x: s / 2, y: 0),
+ end: CGPoint(x: s / 2, y: s),
+ options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
+ )
+ ctx.restoreGState()
+
+ // Viewfinder corner brackets: thick L-strokes, rounded caps/joins, inset ~18%.
+ let inset = s * 0.18
+ let bracketWidth = s * 0.095
+ let arm = s * 0.155
+ let centerline = inset + bracketWidth / 2
+
+ ctx.saveGState()
+ ctx.addPath(squircle)
+ ctx.clip()
+ ctx.setStrokeColor(Palette.white)
+ ctx.setLineWidth(bracketWidth)
+ ctx.setLineCap(.round)
+ ctx.setLineJoin(.round)
+
+ func strokeBracket(cornerX: CGFloat, cornerY: CGFloat, dirX: CGFloat, dirY: CGFloat) {
+ let path = CGMutablePath()
+ path.move(to: CGPoint(x: cornerX + dirX * arm, y: cornerY))
+ path.addLine(to: CGPoint(x: cornerX, y: cornerY))
+ path.addLine(to: CGPoint(x: cornerX, y: cornerY + dirY * arm))
+ ctx.addPath(path)
+ ctx.strokePath()
+ }
+
+ // Top-left, top-right, bottom-left, bottom-right.
+ strokeBracket(cornerX: centerline, cornerY: centerline, dirX: 1, dirY: 1)
+ strokeBracket(cornerX: s - centerline, cornerY: centerline, dirX: -1, dirY: 1)
+ strokeBracket(cornerX: centerline, cornerY: s - centerline, dirX: 1, dirY: -1)
+ strokeBracket(cornerX: s - centerline, cornerY: s - centerline, dirX: -1, dirY: -1)
+
+ // Bold redline slash over the frame, lower-left bracket area → upper-right.
+ let slashWidth = s * 0.078
+ ctx.setStrokeColor(Palette.redline)
+ ctx.setLineWidth(slashWidth)
+ ctx.setLineCap(.round)
+ ctx.setLineJoin(.round)
+ let slashInset = centerline + arm * 0.12
+ let slash = CGMutablePath()
+ slash.move(to: CGPoint(x: slashInset, y: s - slashInset))
+ slash.addLine(to: CGPoint(x: s - slashInset, y: slashInset))
+ ctx.addPath(slash)
+ ctx.strokePath()
+ ctx.restoreGState()
+
+ guard let image = ctx.makeImage() else {
+ fputs("error: failed to materialize master CGImage\n", stderr)
+ exit(1)
+ }
+ return image
+}
+
+private func scaledImage(_ image: CGImage, pixels: Int) -> CGImage {
+ if image.width == pixels && image.height == pixels {
+ return image
+ }
+ let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
+ let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue
+ guard let ctx = CGContext(
+ data: nil,
+ width: pixels,
+ height: pixels,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: colorSpace,
+ bitmapInfo: bitmapInfo
+ ) else {
+ fputs("error: failed to create \(pixels)×\(pixels) scale context\n", stderr)
+ exit(1)
+ }
+ ctx.interpolationQuality = .high
+ ctx.setShouldAntialias(true)
+ ctx.draw(image, in: CGRect(x: 0, y: 0, width: pixels, height: pixels))
+ guard let out = ctx.makeImage() else {
+ fputs("error: failed to scale image to \(pixels)px\n", stderr)
+ exit(1)
+ }
+ return out
+}
+
+private func writePNG(_ image: CGImage, to url: URL) {
+ let dir = url.deletingLastPathComponent()
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ if FileManager.default.fileExists(atPath: url.path) {
+ try? FileManager.default.removeItem(at: url)
+ }
+ guard let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
+ fputs("error: cannot create PNG destination at \(url.path)\n", stderr)
+ exit(1)
+ }
+ CGImageDestinationAddImage(dest, image, nil)
+ if !CGImageDestinationFinalize(dest) {
+ fputs("error: failed to write PNG \(url.path)\n", stderr)
+ exit(1)
+ }
+}
+
+// MARK: - Paths / CLI
+
+private func repoRoot() -> URL {
+ let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
+ let arg0 = URL(fileURLWithPath: CommandLine.arguments[0])
+ let scriptURL: URL
+ if arg0.path.hasPrefix("/") {
+ scriptURL = arg0.standardizedFileURL
+ } else {
+ scriptURL = cwd.appendingPathComponent(arg0.path).standardizedFileURL
+ }
+ let fromScript = scriptURL.deletingLastPathComponent().deletingLastPathComponent()
+ if FileManager.default.fileExists(atPath: fromScript.appendingPathComponent("Package.swift").path) {
+ return fromScript
+ }
+ if FileManager.default.fileExists(atPath: cwd.appendingPathComponent("Package.swift").path) {
+ return cwd
+ }
+ fputs("error: could not find repo root (Package.swift)\n", stderr)
+ exit(1)
+}
+
+private func parsePreviewPath() -> String? {
+ let args = CommandLine.arguments
+ var i = 1
+ var preview: String?
+ while i < args.count {
+ let arg = args[i]
+ if arg == "--preview" {
+ i += 1
+ guard i < args.count else {
+ fputs("error: --preview requires a path\n", stderr)
+ exit(1)
+ }
+ preview = args[i]
+ } else if arg.hasPrefix("--preview=") {
+ preview = String(arg.dropFirst("--preview=".count))
+ } else if arg == "--help" || arg == "-h" {
+ fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr)
+ exit(0)
+ } else {
+ fputs("error: unknown argument \(arg)\n", stderr)
+ fputs("Usage: swift scripts/make-icon.swift [--preview PATH]\n", stderr)
+ exit(1)
+ }
+ i += 1
+ }
+ return preview
+}
+
+private func runIconutil(iconset: URL, icns: URL) {
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
+ proc.arguments = ["-c", "icns", iconset.path, "-o", icns.path]
+ proc.standardOutput = FileHandle.standardOutput
+ proc.standardError = FileHandle.standardError
+ do {
+ try proc.run()
+ proc.waitUntilExit()
+ } catch {
+ fputs("error: failed to launch iconutil: \(error)\n", stderr)
+ exit(1)
+ }
+ if proc.terminationStatus != 0 {
+ fputs("error: iconutil exited \(proc.terminationStatus)\n", stderr)
+ exit(1)
+ }
+}
+
+// MARK: - Main
+
+let root = repoRoot()
+let resources = root.appendingPathComponent("Resources", isDirectory: true)
+let icnsURL = resources.appendingPathComponent("AppIcon.icns")
+let previewPath = parsePreviewPath()
+let fm = FileManager.default
+
+print("==> Drawing \(masterSize)×\(masterSize) master")
+let master = drawMasterIcon(size: masterSize)
+
+let iconset = fm.temporaryDirectory.appendingPathComponent("Redline-AppIcon-\(UUID().uuidString).iconset", isDirectory: true)
+
+do {
+ try fm.createDirectory(at: iconset, withIntermediateDirectories: true)
+ try fm.createDirectory(at: resources, withIntermediateDirectories: true)
+
+ print("==> Writing AppIcon.iconset")
+ for entry in iconsetEntries {
+ let img = scaledImage(master, pixels: entry.pixels)
+ writePNG(img, to: iconset.appendingPathComponent(entry.filename))
+ }
+
+ if fm.fileExists(atPath: icnsURL.path) {
+ try fm.removeItem(at: icnsURL)
+ }
+
+ print("==> Compiling \(icnsURL.path)")
+ runIconutil(iconset: iconset, icns: icnsURL)
+
+ try? fm.removeItem(at: iconset)
+} catch {
+ try? fm.removeItem(at: iconset)
+ fputs("error: \(error)\n", stderr)
+ exit(1)
+}
+
+if let previewPath {
+ let previewURL = URL(fileURLWithPath: previewPath)
+ print("==> Writing 512px preview \(previewURL.path)")
+ writePNG(scaledImage(master, pixels: 512), to: previewURL)
+}
+
+print("App icon: \(icnsURL.path)")