diff --git a/Bin/VersionIcon b/Bin/VersionIcon index 05a10e7..05f837f 100755 Binary files a/Bin/VersionIcon and b/Bin/VersionIcon differ diff --git a/Sources/VersionIcon/Support/IconGeneration.swift b/Sources/VersionIcon/Support/IconGeneration.swift index 0f13bab..194a45e 100644 --- a/Sources/VersionIcon/Support/IconGeneration.swift +++ b/Sources/VersionIcon/Support/IconGeneration.swift @@ -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!)") @@ -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)") @@ -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) diff --git a/Sources/VersionIcon/Support/IconState.swift b/Sources/VersionIcon/Support/IconState.swift new file mode 100644 index 0000000..dad6709 --- /dev/null +++ b/Sources/VersionIcon/Support/IconState.swift @@ -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)) + ) + } +} diff --git a/Sources/VersionIcon/Support/ImageProcessing.swift b/Sources/VersionIcon/Support/ImageProcessing.swift index 7a883a9..209bcca 100644 --- a/Sources/VersionIcon/Support/ImageProcessing.swift +++ b/Sources/VersionIcon/Support/ImageProcessing.swift @@ -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), diff --git a/Sources/VersionIcon/Support/PNGMetadata.swift b/Sources/VersionIcon/Support/PNGMetadata.swift new file mode 100644 index 0000000..5bab14b --- /dev/null +++ b/Sources/VersionIcon/Support/PNGMetadata.swift @@ -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 + } +} diff --git a/Tests/VersionIconTests/VersionIconTests.swift b/Tests/VersionIconTests/VersionIconTests.swift index 3de8153..4de1566 100644 --- a/Tests/VersionIconTests/VersionIconTests.swift +++ b/Tests/VersionIconTests/VersionIconTests.swift @@ -126,6 +126,66 @@ 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), @@ -133,6 +193,8 @@ final class VersionIconTests: XCTestCase { ("testWarnModePrintsErrorButExitsZero", testWarnModePrintsErrorButExitsZero), ("testTitleRotationMustBeWithinBounds", testTitleRotationMustBeWithinBounds), ("testDynamicVariantDiscoverySupportsFlashcardsStyleIconSet", testDynamicVariantDiscoverySupportsFlashcardsStyleIconSet), + ("testSecondRunWithSameInputsKeepsIconsUntouched", testSecondRunWithSameInputsKeepsIconsUntouched), + ("testChangedInputRegeneratesIcon", testChangedInputRegeneratesIcon), ] }