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)")