Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified Bin/VersionIcon
Binary file not shown.
47 changes: 38 additions & 9 deletions Sources/VersionIcon/Support/IconGeneration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,34 @@ func generateIconOutputs(
try validateDesignStyle(designStyle)
let version = try getVersionText(appSetup: appSetup, designStyle: designStyle)

return try variants.map { variant in
let ribbonData = try designStyle.ribbon.map { try imageResourceData(path: $0, kind: "ribbon") }
let titleData = try designStyle.title.map { try imageResourceData(path: $0, kind: "title") }

return try variants.compactMap { variant in
print(" \(variant.key.description)")

let iconImageData: Data
do {
iconImageData = try Data(contentsOf: URL(fileURLWithPath: variant.sourcePath))
} catch {
throw ScriptError.fileNotFound(message: "Unable to read original icon image: \(variant.sourcePath)")
}

let state = IconStateRecord(
version: version,
designStyle: designStyle,
pixelSize: variant.pixelSize,
sourceIconData: iconImageData,
ribbonData: ribbonData,
titleData: titleData
)

if let existingData = try? Data(contentsOf: URL(fileURLWithPath: variant.destinationPath)),
IconStateRecord.stored(in: existingData) == state {
print(" Keeping original file - no change (\(variant.key.description))")
return nil
}

let resizedRibbonImage = resizeImage(fileName: designStyle.ribbon, size: variant.pixelSize)
if designStyle.ribbon != nil, resizedRibbonImage == nil {
throw ScriptError.generalError(message: "Unable to load ribbon image: \(designStyle.ribbon!)")
Expand All @@ -22,13 +47,6 @@ func generateIconOutputs(
throw ScriptError.generalError(message: "Unable to load title image: \(designStyle.title!)")
}

let iconImageData: Data
do {
iconImageData = try Data(contentsOf: URL(fileURLWithPath: variant.sourcePath))
} catch {
throw ScriptError.fileNotFound(message: "Unable to read original icon image: \(variant.sourcePath)")
}

let iconImage = NSImage(size: variant.pixelSize)
guard let bitmap = NSBitmapImageRep(data: iconImageData) else {
throw ScriptError.generalError(message: "Unable to decode original icon image: \(variant.sourcePath)")
Expand Down Expand Up @@ -62,15 +80,26 @@ func generateIconOutputs(
guard let outputData = resizedIcon.pngRepresentation else {
throw ScriptError.generalError(message: "Unable to create PNG data for \(variant.key.description)")
}
guard let stampedData = state.embedded(into: outputData) else {
throw ScriptError.generalError(message: "Unable to embed VersionIcon metadata for \(variant.key.description)")
}

return FileOutput(
destinationPath: variant.destinationPath,
data: outputData,
data: stampedData,
label: variant.key.description
)
}
}

private func imageResourceData(path: String, kind: String) throws -> Data {
do {
return try Data(contentsOf: URL(fileURLWithPath: path))
} catch {
throw ScriptError.fileNotFound(message: "Unable to read \(kind) image: \(path)")
}
}

func prepareRestoreOutputs(variants: [ResolvedIconVariant]) throws -> [FileOutput] {
try variants.map { variant in
let sourceURL = URL(fileURLWithPath: variant.sourcePath)
Expand Down
89 changes: 89 additions & 0 deletions Sources/VersionIcon/Support/IconState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import AppKit
import CryptoKit
import Foundation

/// A record of all inputs that produced a generated icon. It is embedded into the
/// generated PNG as a human-readable JSON `tEXt` chunk and compared on the next run —
/// when no input changed, the icon is not regenerated and git sees no modification.
struct IconStateRecord: Codable, Equatable {
var version: String
var versionStyle: String
var font: String
var fillColor: String
var strokeColor: String
var strokeWidth: Double
var titleSize: Double
var horizontalTitlePosition: Double
var verticalTitlePosition: Double
var titleRotation: Double
var titleAlignment: String
var pixelSize: String
var sourceIconHash: String
var ribbonHash: String?
var titleHash: String?
}

extension IconStateRecord {
static let metadataKeyword = "VersionIcon"

init(
version: String,
designStyle: DesignStyle,
pixelSize: CGSize,
sourceIconData: Data,
ribbonData: Data?,
titleData: Data?
) {
self.version = version
versionStyle = designStyle.versionStyle.rawValue
font = designStyle.titleFont
fillColor = designStyle.titleFillColor.hexString
strokeColor = designStyle.titleStrokeColor.hexString
strokeWidth = designStyle.titleStrokeWidth
titleSize = designStyle.titleSizeRatio
horizontalTitlePosition = designStyle.horizontalTitlePositionRatio
verticalTitlePosition = designStyle.verticalTitlePositionRatio
titleRotation = designStyle.titleRotation
titleAlignment = designStyle.titleAlignment.rawValue
self.pixelSize = "\(Int(pixelSize.width))x\(Int(pixelSize.height))"
sourceIconHash = Self.hash(sourceIconData)
ribbonHash = ribbonData.map(Self.hash)
titleHash = titleData.map(Self.hash)
}

/// The record stored in an existing generated icon, if present.
static func stored(in pngData: Data) -> IconStateRecord? {
guard let json = PNGMetadata.textChunk(in: pngData, keyword: metadataKeyword) else { return nil }
return try? JSONDecoder().decode(IconStateRecord.self, from: Data(json.utf8))
}

/// Returns the PNG data with this record embedded as a `tEXt` chunk.
func embedded(into pngData: Data) -> Data? {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
guard let json = try? encoder.encode(self) else { return nil }

return PNGMetadata.insertingTextChunk(
into: pngData,
keyword: Self.metadataKeyword,
text: String(decoding: json, as: UTF8.self)
)
}

private static func hash(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
}

private extension NSColor {
var hexString: String {
guard let srgb = usingColorSpace(.sRGB) else { return description }

return String(
format: "#%02X%02X%02X",
Int(round(srgb.redComponent * 255)),
Int(round(srgb.greenComponent * 255)),
Int(round(srgb.blueComponent * 255))
)
}
}
17 changes: 0 additions & 17 deletions Sources/VersionIcon/Support/ImageProcessing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,6 @@ extension NSImage {
return tiffData.representation(using: .png, properties: [:])
}

private func imagesMatch(_ other: NSImage) -> Bool {
tiffRepresentation == other.tiffRepresentation
}

func savePNGRepresentationToURL(url: URL, onlyChange: Bool = true) throws {
guard let pngData = pngRepresentation else {
throw ScriptError.generalError(message: "Unable to create PNG data")
}

if let originalImage = NSImage(contentsOf: url), onlyChange, imagesMatch(originalImage) {
print(" Keeping original file - no change")
return
}

try pngData.write(to: url, options: .atomicWrite)
}

func combine(withImage image: NSImage) throws -> NSImage {
guard let foregroundData = image.tiffRepresentation,
let foreground = CIImage(data: foregroundData),
Expand Down
107 changes: 107 additions & 0 deletions Sources/VersionIcon/Support/PNGMetadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation

/// Reading and writing of a custom `tEXt` metadata chunk in PNG files.
/// VersionIcon stores a fingerprint of all generation inputs in the generated icon,
/// so an unchanged icon is not rewritten (and does not show up as modified in git).
enum PNGMetadata {
private static let signature: [UInt8] = [137, 80, 78, 71, 13, 10, 26, 10]

private struct Chunk {
var type: String
var data: [UInt8]
}

/// Returns the text stored in the first `tEXt` chunk with the given keyword, if any.
static func textChunk(in pngData: Data, keyword: String) -> String? {
guard let chunks = chunks(in: pngData) else { return nil }

for chunk in chunks where isTextChunk(chunk, keyword: keyword) {
return String(bytes: chunk.data.dropFirst(keyword.utf8.count + 1), encoding: .utf8)
}

return nil
}

/// Returns a copy of the PNG with a `tEXt` chunk containing the given text inserted before `IEND`.
/// An existing `tEXt` chunk with the same keyword is replaced.
static func insertingTextChunk(into pngData: Data, keyword: String, text: String) -> Data? {
guard var chunks = chunks(in: pngData), chunks.last?.type == "IEND" else { return nil }

chunks.removeAll { isTextChunk($0, keyword: keyword) }
chunks.insert(
Chunk(type: "tEXt", data: [UInt8](keyword.utf8) + [0] + [UInt8](text.utf8)),
at: chunks.count - 1
)

var result = Data(signature)
for chunk in chunks {
result.append(contentsOf: serialized(chunk))
}
return result
}

private static func isTextChunk(_ chunk: Chunk, keyword: String) -> Bool {
let keywordBytes = [UInt8](keyword.utf8)
return chunk.type == "tEXt"
&& chunk.data.count > keywordBytes.count
&& Array(chunk.data.prefix(keywordBytes.count)) == keywordBytes
&& chunk.data[keywordBytes.count] == 0
}

private static func chunks(in pngData: Data) -> [Chunk]? {
let bytes = [UInt8](pngData)
guard bytes.count >= signature.count, Array(bytes.prefix(signature.count)) == signature else { return nil }

var chunks: [Chunk] = []
var offset = signature.count

while offset + 12 <= bytes.count {
let length = Int(bytes[offset]) << 24
| Int(bytes[offset + 1]) << 16
| Int(bytes[offset + 2]) << 8
| Int(bytes[offset + 3])
guard offset + 12 + length <= bytes.count,
let type = String(bytes: bytes[(offset + 4) ..< (offset + 8)], encoding: .ascii)
else { return nil }

chunks.append(Chunk(type: type, data: Array(bytes[(offset + 8) ..< (offset + 8 + length)])))
offset += 12 + length

if type == "IEND" {
break
}
}

return chunks
}

private static func serialized(_ chunk: Chunk) -> [UInt8] {
let typeBytes = [UInt8](chunk.type.utf8)
var bytes = bigEndianBytes(UInt32(chunk.data.count))
bytes += typeBytes
bytes += chunk.data
bytes += bigEndianBytes(crc32(typeBytes + chunk.data))
return bytes
}

private static func bigEndianBytes(_ value: UInt32) -> [UInt8] {
[
UInt8(truncatingIfNeeded: value >> 24),
UInt8(truncatingIfNeeded: value >> 16),
UInt8(truncatingIfNeeded: value >> 8),
UInt8(truncatingIfNeeded: value),
]
}

/// Standard PNG CRC-32 (reflected, polynomial 0xEDB88320).
private static func crc32(_ bytes: [UInt8]) -> UInt32 {
var crc: UInt32 = 0xFFFFFFFF
for byte in bytes {
crc ^= UInt32(byte)
for _ in 0 ..< 8 {
crc = (crc & 1) == 1 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1
}
}
return crc ^ 0xFFFFFFFF
}
}
62 changes: 62 additions & 0 deletions Tests/VersionIconTests/VersionIconTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,75 @@ final class VersionIconTests: XCTestCase {
XCTAssertNotEqual(updatedIconData, originalIconData)
}

func testSecondRunWithSameInputsKeepsIconsUntouched() throws {
let projectRoot = try makeProjectFixture()
defer { try? FileManager.default.removeItem(at: projectRoot) }

let arguments = [
"--resources", repositoryRoot.appendingPathComponent("Bin").path,
"--ribbon", "Blue-TopRight.png",
"--title", "Devel-TopRight.png",
]
let environment = [
"SRCROOT": projectRoot.path,
"PROJECT_DIR": projectRoot.path,
"INFOPLIST_FILE": projectRoot.appendingPathComponent("Info.plist").path,
]

let firstRun = try runVersionIcon(arguments: arguments, environment: environment)
XCTAssertEqual(firstRun.exitCode, 0)
XCTAssertFalse(firstRun.stdout.contains("no change"))

let iconURL = projectRoot
.appendingPathComponent("AppIcon.appiconset")
.appendingPathComponent("Icon-60@2x.png")
let dataAfterFirstRun = try Data(contentsOf: iconURL)

let secondRun = try runVersionIcon(arguments: arguments, environment: environment)
XCTAssertEqual(secondRun.exitCode, 0)
XCTAssertEqual(
secondRun.stdout.components(separatedBy: "no change").count - 1,
legacyIconImages.count,
"All icon variants should be kept untouched on the second run"
)
XCTAssertEqual(try Data(contentsOf: iconURL), dataAfterFirstRun)
}

func testChangedInputRegeneratesIcon() throws {
let projectRoot = try makeProjectFixture()
defer { try? FileManager.default.removeItem(at: projectRoot) }

let arguments = [
"--resources", repositoryRoot.appendingPathComponent("Bin").path,
"--ribbon", "Blue-TopRight.png",
"--title", "Devel-TopRight.png",
]
let environment = [
"SRCROOT": projectRoot.path,
"PROJECT_DIR": projectRoot.path,
"INFOPLIST_FILE": projectRoot.appendingPathComponent("Info.plist").path,
]

let firstRun = try runVersionIcon(arguments: arguments, environment: environment)
XCTAssertEqual(firstRun.exitCode, 0)

let secondRun = try runVersionIcon(
arguments: arguments + ["--fillColor", "#FF0000"],
environment: environment
)
XCTAssertEqual(secondRun.exitCode, 0)
XCTAssertFalse(secondRun.stdout.contains("no change"))
}

static var allTests = [
("testHelpPrintsUsage", testHelpPrintsUsage),
("testMissingResourcesReportsResourcesOption", testMissingResourcesReportsResourcesOption),
("testOriginalModeFailsWhenRequiredIconFileIsMissing", testOriginalModeFailsWhenRequiredIconFileIsMissing),
("testWarnModePrintsErrorButExitsZero", testWarnModePrintsErrorButExitsZero),
("testTitleRotationMustBeWithinBounds", testTitleRotationMustBeWithinBounds),
("testDynamicVariantDiscoverySupportsFlashcardsStyleIconSet", testDynamicVariantDiscoverySupportsFlashcardsStyleIconSet),
("testSecondRunWithSameInputsKeepsIconsUntouched", testSecondRunWithSameInputsKeepsIconsUntouched),
("testChangedInputRegeneratesIcon", testChangedInputRegeneratesIcon),
]
}

Expand Down