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.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Pods path correct? We should be dropping CocoaPods in favour of SPM.

--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 <Icon ribbon>`
Expand Down Expand Up @@ -100,6 +117,9 @@ fi
* `--resources <VersionIcon resources path>`
* 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 <Generated .xcassets path>`
* 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 <fail|warn>`
* Controls whether VersionIcon should fail the build (`fail`, default) or print the error and continue (`warn`).

Expand Down
71 changes: 63 additions & 8 deletions Sources/VersionIcon/Support/AppSetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions Sources/VersionIcon/Support/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ struct DesignStyle {
struct ScriptSetup {
var appIcon: String
var appIconOriginal: String
var outputAssetCatalog: String?
var resourcesPath: String
}

Expand Down
14 changes: 13 additions & 1 deletion Sources/VersionIcon/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ let appIcon = moderator.add(Argument<String?>
let appIconOriginal = moderator.add(Argument<String?>
.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<String?>
.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<String?>
Expand Down Expand Up @@ -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)

Expand Down
80 changes: 80 additions & 0 deletions Tests/VersionIconTests/VersionIconTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -195,6 +273,8 @@ final class VersionIconTests: XCTestCase {
("testDynamicVariantDiscoverySupportsFlashcardsStyleIconSet", testDynamicVariantDiscoverySupportsFlashcardsStyleIconSet),
("testSecondRunWithSameInputsKeepsIconsUntouched", testSecondRunWithSameInputsKeepsIconsUntouched),
("testChangedInputRegeneratesIcon", testChangedInputRegeneratesIcon),
("testGeneratedAssetCatalogModeLeavesSourceIconsUntouched", testGeneratedAssetCatalogModeLeavesSourceIconsUntouched),
("testOutputAssetCatalogRejectsSameCatalogAsOriginal", testOutputAssetCatalogRejectsSameCatalogAsOriginal),
]
}

Expand Down
9 changes: 9 additions & 0 deletions pr_description.md

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this file.

Original file line number Diff line number Diff line change
@@ -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.