diff --git a/Bin/VersionIcon b/Bin/VersionIcon index 05f837f..29fb300 100755 Binary files a/Bin/VersionIcon and b/Bin/VersionIcon differ diff --git a/README.md b/README.md index 162e6ee..90d00a5 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,23 @@ fi * Move this script phase above the Copy Bundle Resources phase. * If you need to use your own ribbon or title asset, you can specify full path to image file +### Generated asset catalog mode + +By default, VersionIcon keeps the historical behavior and writes generated images into the `--appIcon` asset. For new projects, use `--outputAssetCatalog` to keep source assets immutable: + +```shell +"Pods/VersionIcon/Bin/VersionIcon" \ + --appIcon "AppIcon-${CONFIGURATION}" \ + --appIconOriginal AppIconOriginal \ + --outputAssetCatalog "${SRCROOT}/VersionIconGenerated.xcassets" \ + --resources "Pods/VersionIcon/Bin" \ + --ribbon Blue-TopRight.png \ + --title Devel-TopRight.png \ + --on-error warn +``` + +Add `VersionIconGenerated.xcassets` to the target's Copy Bundle Resources phase, keep the VersionIcon Run Script phase before it, and set the target's Primary App Icon Set Name to `AppIcon-${CONFIGURATION}`. The generated catalog is a build artifact and should be ignored by source control. Using a distinct output app icon name per configuration prevents switching configurations from rewriting another configuration's generated files. + ## Parameters #### Ribbon Style * `--ribbon ` @@ -100,6 +117,9 @@ fi * `--resources ` * Default path where Ribbons and Titles folders are located. It is not necessary to set when script is executed as a build phase in Xcode +* `--outputAssetCatalog ` + * Optional output asset catalog for generated icons. When set, VersionIcon creates the `--appIcon` app icon set inside this catalog and never modifies an app icon set in the project sources. + * `--on-error ` * Controls whether VersionIcon should fail the build (`fail`, default) or print the error and continue (`warn`). diff --git a/Sources/VersionIcon/Support/AppSetup.swift b/Sources/VersionIcon/Support/AppSetup.swift index ba75879..882344c 100644 --- a/Sources/VersionIcon/Support/AppSetup.swift +++ b/Sources/VersionIcon/Support/AppSetup.swift @@ -45,14 +45,6 @@ func getAppSetup(scriptSetup: ScriptSetup) throws -> AppSetup { sourceRootPath: sourceRootPath ) - guard let appIconFolder = try locateAppIconFolder( - named: scriptSetup.appIcon, - projectDir: projectDir, - sourceRootFolder: sourceFolder - ) else { - throw ScriptError.folderNotFound(message: "\(scriptSetup.appIcon).appiconset - icon asset folder") - } - guard let originalAppIconFolder = try locateAppIconFolder( named: scriptSetup.appIconOriginal, projectDir: projectDir, @@ -61,6 +53,40 @@ func getAppSetup(scriptSetup: ScriptSetup) throws -> AppSetup { throw ScriptError.folderNotFound(message: "\(scriptSetup.appIconOriginal).appiconset - source icon asset for modifications") } + let appIconFolder: Folder + if let outputAssetCatalog = scriptSetup.outputAssetCatalog { + let outputAppIconFolderPath = outputAssetCatalog.appendingPathComponent(path: "\(scriptSetup.appIcon).appiconset") + + let outputURL = URL(fileURLWithPath: outputAppIconFolderPath).standardizedFileURL + let originalURL = URL(fileURLWithPath: originalAppIconFolder.path).standardizedFileURL + guard outputURL != originalURL else { + throw ScriptError.argumentError(message: "Generated asset catalog must not contain the original app icon") + } + + let outputCatalogURL = outputURL.deletingLastPathComponent() + let originalCatalogURL = originalURL.deletingLastPathComponent() + guard outputCatalogURL != originalCatalogURL else { + throw ScriptError.argumentError(message: "Generated asset catalog must not be the same catalog as the original app icon's catalog") + } + + try prepareGeneratedAppIconFolder( + at: outputAppIconFolderPath, + sourceFolder: originalAppIconFolder + ) + + appIconFolder = try Folder(path: outputAppIconFolderPath) + } else { + guard let existingAppIconFolder = try locateAppIconFolder( + named: scriptSetup.appIcon, + projectDir: projectDir, + sourceRootFolder: sourceFolder + ) else { + throw ScriptError.folderNotFound(message: "\(scriptSetup.appIcon).appiconset - icon asset folder") + } + + appIconFolder = existingAppIconFolder + } + return try AppSetup( sourceRootPath: sourceRootPath, projectDir: projectDir, @@ -72,6 +98,35 @@ func getAppSetup(scriptSetup: ScriptSetup) throws -> AppSetup { ) } +private func prepareGeneratedAppIconFolder(at path: String, sourceFolder: Folder) throws { + let fileManager = FileManager.default + let outputFolderURL = URL(fileURLWithPath: path, isDirectory: true) + let outputCatalogURL = outputFolderURL.deletingLastPathComponent() + + try fileManager.createDirectory(at: outputFolderURL, withIntermediateDirectories: true) + + let catalogContentsURL = outputCatalogURL.appendingPathComponent("Contents.json") + if !fileManager.fileExists(atPath: catalogContentsURL.path) { + let catalogContents = """ + { + "info" : { + "author" : "xcode", + "version" : 1 + } + } + """ + try Data(catalogContents.utf8).write(to: catalogContentsURL, options: .atomic) + } + + let sourceContents = try sourceFolder.file(named: "Contents.json") + let sourceData = try sourceContents.read() + let outputContentsURL = outputFolderURL.appendingPathComponent("Contents.json") + let outputData = try? Data(contentsOf: outputContentsURL) + if outputData != sourceData { + try sourceData.write(to: outputContentsURL, options: .atomic) + } +} + func iconMetadata(iconFolder: Folder) throws -> IconMetadata { let contentsFile = try iconFolder.file(named: "Contents.json") let jsonData = try contentsFile.read() diff --git a/Sources/VersionIcon/Support/Models.swift b/Sources/VersionIcon/Support/Models.swift index 947376d..d8e75cf 100644 --- a/Sources/VersionIcon/Support/Models.swift +++ b/Sources/VersionIcon/Support/Models.swift @@ -41,6 +41,7 @@ struct DesignStyle { struct ScriptSetup { var appIcon: String var appIconOriginal: String + var outputAssetCatalog: String? var resourcesPath: String } diff --git a/Sources/VersionIcon/main.swift b/Sources/VersionIcon/main.swift index a548474..d20111a 100644 --- a/Sources/VersionIcon/main.swift +++ b/Sources/VersionIcon/main.swift @@ -19,6 +19,13 @@ let appIcon = moderator.add(Argument let appIconOriginal = moderator.add(Argument .optionWithValue("appIconOriginal", name: "The name of original app icon asset", description: "This asset is used as backup of original icon.").default("AppIconOriginal")) +let outputAssetCatalog = moderator.add(Argument + .optionWithValue( + "outputAssetCatalog", + name: "Generated asset catalog path", + description: "Optional .xcassets directory where the generated app icon is written. When provided, source assets are not modified." + )) + // DesignStyle elements var ribbon = moderator.add(Argument @@ -90,7 +97,12 @@ do { throw ScriptError.argumentError(message: "You must specify the resources path using --resources parameter") } - let scriptSetup = ScriptSetup(appIcon: appIcon.value, appIconOriginal: appIconOriginal.value, resourcesPath: resourcesPath) + let scriptSetup = ScriptSetup( + appIcon: appIcon.value, + appIconOriginal: appIconOriginal.value, + outputAssetCatalog: outputAssetCatalog.value, + resourcesPath: resourcesPath + ) let appSetup = try getAppSetup(scriptSetup: scriptSetup) let resolvedVariants = try resolveIconVariants(appSetup: appSetup) diff --git a/Tests/VersionIconTests/VersionIconTests.swift b/Tests/VersionIconTests/VersionIconTests.swift index 4de1566..1a82c04 100644 --- a/Tests/VersionIconTests/VersionIconTests.swift +++ b/Tests/VersionIconTests/VersionIconTests.swift @@ -186,6 +186,84 @@ final class VersionIconTests: XCTestCase { XCTAssertFalse(secondRun.stdout.contains("no change")) } + func testGeneratedAssetCatalogModeLeavesSourceIconsUntouched() throws { + let projectRoot = try makeProjectFixture() + defer { try? FileManager.default.removeItem(at: projectRoot) } + + let sourceIconURL = projectRoot + .appendingPathComponent("AppIcon.appiconset") + .appendingPathComponent("Icon-60@2x.png") + let sourceIconData = try Data(contentsOf: sourceIconURL) + let generatedCatalogURL = projectRoot.appendingPathComponent("VersionIconGenerated.xcassets") + let environment = [ + "SRCROOT": projectRoot.path, + "PROJECT_DIR": projectRoot.path, + "INFOPLIST_FILE": projectRoot.appendingPathComponent("Info.plist").path, + ] + + let developmentRun = try runVersionIcon( + arguments: [ + "--resources", repositoryRoot.appendingPathComponent("Bin").path, + "--appIcon", "AppIcon-Development", + "--outputAssetCatalog", generatedCatalogURL.path, + "--ribbon", "Blue-TopRight.png", + "--title", "Devel-TopRight.png", + ], + environment: environment + ) + XCTAssertEqual(developmentRun.exitCode, 0) + + let stagingRun = try runVersionIcon( + arguments: [ + "--resources", repositoryRoot.appendingPathComponent("Bin").path, + "--appIcon", "AppIcon-Staging", + "--outputAssetCatalog", generatedCatalogURL.path, + "--ribbon", "Blue-TopRight.png", + "--title", "Devel-TopRight.png", + ], + environment: environment + ) + XCTAssertEqual(stagingRun.exitCode, 0) + XCTAssertEqual(try Data(contentsOf: sourceIconURL), sourceIconData) + + let developmentOutput = generatedCatalogURL + .appendingPathComponent("AppIcon-Development.appiconset") + .appendingPathComponent("Icon-60@2x.png") + let stagingOutput = generatedCatalogURL + .appendingPathComponent("AppIcon-Staging.appiconset") + .appendingPathComponent("Icon-60@2x.png") + XCTAssertTrue(FileManager.default.fileExists(atPath: developmentOutput.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: stagingOutput.path)) + XCTAssertNotEqual(try Data(contentsOf: developmentOutput), sourceIconData) + XCTAssertNotEqual(try Data(contentsOf: stagingOutput), sourceIconData) + } + + func testOutputAssetCatalogRejectsSameCatalogAsOriginal() throws { + let projectRoot = try makeProjectFixture() + defer { try? FileManager.default.removeItem(at: projectRoot) } + + let result = try runVersionIcon( + arguments: [ + "--resources", repositoryRoot.appendingPathComponent("Bin").path, + "--appIcon", "AppIcon-Development", + "--outputAssetCatalog", projectRoot.path, + "--ribbon", "Blue-TopRight.png", + "--title", "Devel-TopRight.png", + ], + environment: [ + "SRCROOT": projectRoot.path, + "PROJECT_DIR": projectRoot.path, + "INFOPLIST_FILE": projectRoot.appendingPathComponent("Info.plist").path, + ] + ) + + XCTAssertNotEqual(result.exitCode, 0) + XCTAssertTrue(result.stdout.contains("same catalog as the original app icon's catalog")) + XCTAssertFalse(FileManager.default.fileExists( + atPath: projectRoot.appendingPathComponent("AppIcon-Development.appiconset").path + )) + } + static var allTests = [ ("testHelpPrintsUsage", testHelpPrintsUsage), ("testMissingResourcesReportsResourcesOption", testMissingResourcesReportsResourcesOption), @@ -195,6 +273,8 @@ final class VersionIconTests: XCTestCase { ("testDynamicVariantDiscoverySupportsFlashcardsStyleIconSet", testDynamicVariantDiscoverySupportsFlashcardsStyleIconSet), ("testSecondRunWithSameInputsKeepsIconsUntouched", testSecondRunWithSameInputsKeepsIconsUntouched), ("testChangedInputRegeneratesIcon", testChangedInputRegeneratesIcon), + ("testGeneratedAssetCatalogModeLeavesSourceIconsUntouched", testGeneratedAssetCatalogModeLeavesSourceIconsUntouched), + ("testOutputAssetCatalogRejectsSameCatalogAsOriginal", testOutputAssetCatalogRejectsSameCatalogAsOriginal), ] } diff --git a/pr_description.md b/pr_description.md new file mode 100644 index 0000000..2a66e5a --- /dev/null +++ b/pr_description.md @@ -0,0 +1,9 @@ +This PR adds an opt-in generated asset catalog mode so VersionIcon can create configuration-specific app icons without modifying tracked source assets. Existing projects retain the historical behavior unless they pass the new option. + +## Changes +- Add `--outputAssetCatalog` for generating app icon sets outside the source asset catalog. +- Initialize and synchronize generated `.xcassets` and `.appiconset` metadata from `AppIconOriginal`. +- Document configuration-specific output names and Xcode build-phase setup. +- Add regression coverage proving multiple generated configurations leave source icons untouched. +- Update the distributed `Bin/VersionIcon` binary. +- Reject an `--outputAssetCatalog` that resolves to the same catalog as `--appIconOriginal`, in addition to the existing exact-folder check.