Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52e68a9d5 |
@@ -18,6 +18,8 @@
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
Reference in New Issue
Block a user