feat(app): app icon asset catalog + make-icon tool
(cherry picked from commit 14d34d98c5)
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"images" : [
|
||||
{ "idiom" : "mac", "scale" : "1x", "size" : "16x16", "filename" : "icon_16.png" },
|
||||
{ "idiom" : "mac", "scale" : "2x", "size" : "16x16", "filename" : "icon_32.png" },
|
||||
{ "idiom" : "mac", "scale" : "1x", "size" : "32x32", "filename" : "icon_32.png" },
|
||||
{ "idiom" : "mac", "scale" : "2x", "size" : "32x32", "filename" : "icon_64.png" },
|
||||
{ "idiom" : "mac", "scale" : "1x", "size" : "128x128", "filename" : "icon_128.png" },
|
||||
{ "idiom" : "mac", "scale" : "2x", "size" : "128x128", "filename" : "icon_256.png" },
|
||||
{ "idiom" : "mac", "scale" : "1x", "size" : "256x256", "filename" : "icon_256.png" },
|
||||
{ "idiom" : "mac", "scale" : "2x", "size" : "256x256", "filename" : "icon_512.png" },
|
||||
{ "idiom" : "mac", "scale" : "1x", "size" : "512x512", "filename" : "icon_512.png" },
|
||||
{ "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "icon_1024.png" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 663 KiB |
|
After Width: | Height: | Size: 20 KiB |
BIN
Sources/TVAnarchy/Assets.xcassets/AppIcon.appiconset/icon_16.png
Normal file
|
After Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 61 KiB |
BIN
Sources/TVAnarchy/Assets.xcassets/AppIcon.appiconset/icon_32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 197 KiB |
BIN
Sources/TVAnarchy/Assets.xcassets/AppIcon.appiconset/icon_64.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
6
Sources/TVAnarchy/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ targets:
|
|||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: local.lilith.TVAnarchy
|
||||
GENERATE_INFOPLIST_FILE: "NO"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Sources/TVAnarchy/Assets.xcassets
|
||||
# The human version (CFBundleShortVersionString) comes from MARKETING_VERSION
|
||||
# via info.properties above — reliable. The dynamic git SHA / build time live
|
||||
# in a generated Swift constant (Sources/TVAnarchyCore/BuildStamp.swift,
|
||||
|
|
|
|||
179
tools/make-icon.swift
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
#!/usr/bin/env swift
|
||||
// Renders the TVAnarchy app icon at every size the macOS asset catalog needs,
|
||||
// plus a standalone 1024 master, using only CoreGraphics (no external deps).
|
||||
//
|
||||
// Concept: a sleek black television showing a glowing crimson anarchist
|
||||
// circle-A. TV (rounded screen, bezel, antennae, faint CRT scanlines) +
|
||||
// Anarchy (the overshooting circle-A). The circle-A is the hero so the mark
|
||||
// stays legible at 16px when the finer TV details drop out.
|
||||
//
|
||||
// Usage: swift tools/make-icon.swift <output-dir>
|
||||
// writes icon_<px>.png for each size into <output-dir>.
|
||||
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "."
|
||||
|
||||
// Unique pixel sizes referenced by the macOS AppIcon set (1x/2x of 16…512).
|
||||
let sizes = [16, 32, 64, 128, 256, 512, 1024]
|
||||
|
||||
func color(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat = 1) -> CGColor {
|
||||
CGColor(red: r/255, green: g/255, blue: b/255, alpha: a)
|
||||
}
|
||||
|
||||
let crimson = color(255, 38, 71) // hero red
|
||||
let crimsonDeep = color(190, 18, 45)
|
||||
let screenTop = color(28, 28, 32) // TV body / screen, dark charcoal
|
||||
let screenBottom = color(6, 6, 8)
|
||||
let bezel = color(44, 44, 50)
|
||||
let scanline = color(120, 200, 255, 0.05)
|
||||
|
||||
func renderIcon(px: Int) -> CGImage {
|
||||
let s = CGFloat(px)
|
||||
let cs = CGColorSpaceCreateDeviceRGB()
|
||||
let ctx = CGContext(data: nil, width: px, height: px, bitsPerComponent: 8,
|
||||
bytesPerRow: 0, space: cs,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
||||
ctx.setAllowsAntialiasing(true)
|
||||
ctx.interpolationQuality = .high
|
||||
|
||||
// Detail fades out at tiny sizes so the icon doesn't turn to mud.
|
||||
let fine = s >= 64
|
||||
|
||||
// --- rounded-rect TV body (the macOS "squircle" footprint) -----------
|
||||
let inset = s * 0.085
|
||||
let bodyRect = CGRect(x: inset, y: inset, width: s - inset*2, height: s - inset*2)
|
||||
let corner = s * 0.225
|
||||
let bodyPath = CGPath(roundedRect: bodyRect, cornerWidth: corner, cornerHeight: corner, transform: nil)
|
||||
|
||||
// Drop shadow under the body for depth (skip on the smallest tiles).
|
||||
if fine {
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: -s*0.012), blur: s*0.04,
|
||||
color: color(0, 0, 0, 0.55))
|
||||
ctx.addPath(bodyPath); ctx.setFillColor(screenBottom); ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
// Body gradient (top-lit charcoal → near-black).
|
||||
ctx.saveGState()
|
||||
ctx.addPath(bodyPath); ctx.clip()
|
||||
let grad = CGGradient(colorsSpace: cs, colors: [screenTop, screenBottom] as CFArray,
|
||||
locations: [0, 1])!
|
||||
ctx.drawLinearGradient(grad, start: CGPoint(x: 0, y: s), end: CGPoint(x: 0, y: 0), options: [])
|
||||
|
||||
// Faint CRT scanlines across the screen.
|
||||
if fine {
|
||||
ctx.setFillColor(scanline)
|
||||
let step = max(s * 0.018, 2)
|
||||
var y = bodyRect.minY
|
||||
while y < bodyRect.maxY {
|
||||
ctx.fill(CGRect(x: bodyRect.minX, y: y, width: bodyRect.width, height: step*0.45))
|
||||
y += step
|
||||
}
|
||||
// Soft screen vignette / glow toward center.
|
||||
let glow = CGGradient(colorsSpace: cs,
|
||||
colors: [color(255, 38, 71, 0.16), color(255, 38, 71, 0)] as CFArray,
|
||||
locations: [0, 1])!
|
||||
ctx.drawRadialGradient(glow,
|
||||
startCenter: CGPoint(x: s/2, y: s*0.52), startRadius: 0,
|
||||
endCenter: CGPoint(x: s/2, y: s*0.52), endRadius: s*0.42, options: [])
|
||||
}
|
||||
ctx.restoreGState()
|
||||
|
||||
// Inner bezel stroke (screen edge).
|
||||
if fine {
|
||||
ctx.addPath(bodyPath)
|
||||
ctx.setStrokeColor(bezel)
|
||||
ctx.setLineWidth(s * 0.012)
|
||||
ctx.strokePath()
|
||||
}
|
||||
|
||||
// --- antennae (two thin rods rising in a V from behind the body) -----
|
||||
if fine {
|
||||
let apexL = CGPoint(x: s*0.40, y: s*0.985)
|
||||
let apexR = CGPoint(x: s*0.60, y: s*0.985)
|
||||
let base = CGPoint(x: s*0.50, y: bodyRect.maxY - s*0.01)
|
||||
ctx.setStrokeColor(bezel)
|
||||
ctx.setLineWidth(s * 0.018)
|
||||
ctx.setLineCap(.round)
|
||||
ctx.beginPath(); ctx.move(to: base); ctx.addLine(to: apexL); ctx.strokePath()
|
||||
ctx.beginPath(); ctx.move(to: base); ctx.addLine(to: apexR); ctx.strokePath()
|
||||
for p in [apexL, apexR] {
|
||||
ctx.setFillColor(crimson)
|
||||
ctx.fillEllipse(in: CGRect(x: p.x - s*0.018, y: p.y - s*0.018, width: s*0.036, height: s*0.036))
|
||||
}
|
||||
}
|
||||
|
||||
// --- anarchist circle-A (hero mark) ----------------------------------
|
||||
let cx = s * 0.5, cy = s * 0.5
|
||||
let R = s * 0.255 // ring radius
|
||||
let lw = s * 0.062 // stroke weight
|
||||
|
||||
// Glow halo behind the mark.
|
||||
if fine {
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: .zero, blur: s*0.05, color: color(255, 38, 71, 0.9))
|
||||
ctx.setStrokeColor(crimson)
|
||||
ctx.setLineWidth(lw)
|
||||
ctx.addArc(center: CGPoint(x: cx, y: cy), radius: R, startAngle: 0, endAngle: .pi*2, clockwise: false)
|
||||
ctx.strokePath()
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
// The ring.
|
||||
let ringGrad = fine
|
||||
ctx.setLineWidth(lw)
|
||||
ctx.setLineCap(.butt)
|
||||
if ringGrad {
|
||||
ctx.saveGState()
|
||||
ctx.addArc(center: CGPoint(x: cx, y: cy), radius: R, startAngle: 0, endAngle: .pi*2, clockwise: false)
|
||||
ctx.setLineWidth(lw); ctx.replacePathWithStrokedPath(); ctx.clip()
|
||||
let g = CGGradient(colorsSpace: cs, colors: [crimson, crimsonDeep] as CFArray, locations: [0, 1])!
|
||||
ctx.drawLinearGradient(g, start: CGPoint(x: 0, y: cy+R), end: CGPoint(x: 0, y: cy-R), options: [])
|
||||
ctx.restoreGState()
|
||||
} else {
|
||||
ctx.setStrokeColor(crimson)
|
||||
ctx.addArc(center: CGPoint(x: cx, y: cy), radius: R, startAngle: 0, endAngle: .pi*2, clockwise: false)
|
||||
ctx.strokePath()
|
||||
}
|
||||
|
||||
// The "A": two legs from an apex, plus the crossbar that overshoots the
|
||||
// ring on both sides (the defining feature of the anarchy symbol).
|
||||
let apexY = cy + R * 0.92
|
||||
let footY = cy - R * 0.86
|
||||
let halfW = R * 0.62
|
||||
let apex = CGPoint(x: cx, y: apexY)
|
||||
let footL = CGPoint(x: cx - halfW, y: footY)
|
||||
let footR = CGPoint(x: cx + halfW, y: footY)
|
||||
|
||||
ctx.setStrokeColor(crimson)
|
||||
ctx.setLineWidth(lw)
|
||||
ctx.setLineCap(.round)
|
||||
ctx.setLineJoin(.round)
|
||||
// Legs.
|
||||
ctx.beginPath(); ctx.move(to: footL); ctx.addLine(to: apex); ctx.addLine(to: footR); ctx.strokePath()
|
||||
// Overshooting crossbar.
|
||||
let barY = cy - R * 0.04
|
||||
let over = R * (fine ? 0.34 : 0.22)
|
||||
ctx.beginPath()
|
||||
ctx.move(to: CGPoint(x: cx - halfW*0.52 - over, y: barY))
|
||||
ctx.addLine(to: CGPoint(x: cx + halfW*0.52 + over, y: barY))
|
||||
ctx.strokePath()
|
||||
|
||||
return ctx.makeImage()!
|
||||
}
|
||||
|
||||
for px in sizes {
|
||||
let img = renderIcon(px: px)
|
||||
let rep = NSBitmapImageRep(cgImage: img)
|
||||
rep.size = NSSize(width: px, height: px)
|
||||
guard let data = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("failed to encode \(px)\n".data(using: .utf8)!); exit(1)
|
||||
}
|
||||
let url = URL(fileURLWithPath: outDir).appendingPathComponent("icon_\(px).png")
|
||||
try! data.write(to: url)
|
||||
print("wrote \(url.lastPathComponent)")
|
||||
}
|
||||