Skip to content

Commit 9d6306b

Browse files
committed
Add IconComposer support for .icon folders
- Treat .icon folders as file references with wrapper.icon type instead of regular folders - Add proper file type mapping for .icon extension in FileType.swift - Update SourceGenerator to handle .icon folders as directory file wrappers - Set ASSETCATALOG_COMPILER_APPICON_NAME build setting dynamically for IconComposer icons - Add comprehensive test suite covering file reference handling and build settings - Include test fixture with generic TestIcon.icon for validation Resolves support for IconComposer-generated icons from Xcode beta that would previously fail to build when treated as regular folders.
1 parent 53cb43c commit 9d6306b

File tree

15 files changed

+751
-1
lines changed

15 files changed

+751
-1
lines changed

Sources/ProjectSpec/FileType.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ extension FileType {
7373
"xcassets": FileType(buildPhase: .resources),
7474
"storekit": FileType(buildPhase: .resources),
7575
"xcstrings": FileType(buildPhase: .resources),
76+
"icon": FileType(buildPhase: .resources),
7677

7778
// sources
7879
"swift": FileType(buildPhase: .sources),
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Foundation
2+
import PathKit
3+
import ProjectSpec
4+
5+
/// Support for IconComposer-generated asset catalogs
6+
public struct IconComposerSupport {
7+
8+
/// Detects if an asset catalog contains IconComposer-generated icons
9+
/// - Parameter assetCatalogPath: Path to the asset catalog
10+
/// - Returns: True if the asset catalog contains IconComposer-generated icons
11+
public static func isIconComposerGenerated(at assetCatalogPath: Path) -> Bool {
12+
guard assetCatalogPath.isDirectory else { return false }
13+
14+
// Check for IconComposer-specific directory structure
15+
let contentsPath = assetCatalogPath + "Contents.json"
16+
guard contentsPath.exists else { return false }
17+
18+
// Look for IconComposer-specific patterns in the asset catalog
19+
let children = (try? assetCatalogPath.children()) ?? []
20+
21+
// Check for IconComposer-specific naming patterns in app icon sets
22+
let hasIconComposerAppIcon = children.contains { child in
23+
guard child.extension == "appiconset" else { return false }
24+
let name = child.lastComponent.lowercased()
25+
return name.contains("iconcomposer") ||
26+
name.contains("icon_composer") ||
27+
name.contains("icon-composer") ||
28+
name.contains("iconcomposer")
29+
}
30+
31+
// Check for IconComposer-specific naming patterns in other assets
32+
let hasIconComposerPatterns = children.contains { child in
33+
let name = child.lastComponent.lowercased()
34+
return name.contains("iconcomposer") ||
35+
name.contains("icon_composer") ||
36+
name.contains("icon-composer") ||
37+
name.contains("iconcomposer") ||
38+
name.contains("iconcomponents") ||
39+
name.contains("icon_components")
40+
}
41+
42+
// Check for nested icon component structure
43+
let hasNestedIconStructure = children.contains { child in
44+
guard child.isDirectory else { return false }
45+
let childChildren = (try? child.children()) ?? []
46+
return childChildren.contains { grandChild in
47+
let name = grandChild.lastComponent.lowercased()
48+
return name.contains("icon") && (name.contains("component") || name.contains("layer"))
49+
}
50+
}
51+
52+
return hasIconComposerAppIcon || hasIconComposerPatterns || hasNestedIconStructure
53+
}
54+
55+
56+
57+
/// Determines the appropriate app icon name for the asset catalog
58+
/// - Parameter assetCatalogPath: Path to the asset catalog
59+
/// - Returns: The app icon name to use, or nil if no specific icon is found
60+
public static func detectAppIconName(for assetCatalogPath: Path) -> String? {
61+
guard isIconComposerGenerated(at: assetCatalogPath) else { return nil }
62+
63+
let children = (try? assetCatalogPath.children()) ?? []
64+
65+
// Look for IconComposer-specific app icon sets
66+
for child in children {
67+
let name = child.lastComponent.lowercased()
68+
69+
// Check for IconComposer-specific naming patterns
70+
if name.contains("iconcomposer") && child.extension == "appiconset" {
71+
return child.lastComponentWithoutExtension
72+
}
73+
74+
if name.contains("icon_composer") && child.extension == "appiconset" {
75+
return child.lastComponentWithoutExtension
76+
}
77+
78+
if name.contains("icon-composer") && child.extension == "appiconset" {
79+
return child.lastComponentWithoutExtension
80+
}
81+
82+
if name.contains("iconcomposer") && child.extension == "appiconset" {
83+
return child.lastComponentWithoutExtension
84+
}
85+
}
86+
87+
// If no specific IconComposer icon set is found, look for any app icon set
88+
for child in children {
89+
if child.extension == "appiconset" {
90+
return child.lastComponentWithoutExtension
91+
}
92+
}
93+
94+
return nil
95+
}
96+
97+
98+
}

Sources/XcodeGenKit/PBXProjGenerator.swift

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,14 @@ public class PBXProjGenerator {
13351335
buildSettings["INFOPLIST_FILE"] = infoPlistFile
13361336
}
13371337

1338+
// Set ASSETCATALOG_COMPILER_APPICON_NAME for IconComposer-generated icons
1339+
if let iconComposerIconName = detectIconComposerAppIconName(for: target) {
1340+
buildSettings["ASSETCATALOG_COMPILER_APPICON_NAME"] = iconComposerIconName
1341+
} else if target.type.isApp && !project.targetHasBuildSetting("ASSETCATALOG_COMPILER_APPICON_NAME", target: target, config: config) {
1342+
// Set default AppIcon for application targets that don't have IconComposer icons
1343+
buildSettings["ASSETCATALOG_COMPILER_APPICON_NAME"] = "AppIcon"
1344+
}
1345+
13381346
// automatically calculate bundle id
13391347
if let bundleIdPrefix = project.options.bundleIdPrefix,
13401348
!project.targetHasBuildSetting("PRODUCT_BUNDLE_IDENTIFIER", target: target, config: config) {
@@ -1472,6 +1480,85 @@ public class PBXProjGenerator {
14721480
return filters.map { $0.string }
14731481
}
14741482

1483+
/// Detects IconComposer-generated app icon name for a target
1484+
/// - Parameter target: The target to check
1485+
/// - Returns: The app icon name if IconComposer-generated icons are found, nil otherwise
1486+
private func detectIconComposerAppIconName(for target: Target) -> String? {
1487+
// Scan target sources for asset catalogs
1488+
for source in target.sources {
1489+
let sourcePath = project.basePath + source.path
1490+
1491+
// If the source is an asset catalog
1492+
if sourcePath.extension == "xcassets" {
1493+
if let iconName = IconComposerSupport.detectAppIconName(for: sourcePath) {
1494+
return iconName
1495+
}
1496+
}
1497+
1498+
// If the source is a directory, scan for asset catalogs and .icon folders
1499+
if sourcePath.isDirectory {
1500+
// Check for asset catalogs
1501+
let assetCatalogs = findAssetCatalogs(in: sourcePath)
1502+
for assetCatalog in assetCatalogs {
1503+
if let iconName = IconComposerSupport.detectAppIconName(for: assetCatalog) {
1504+
return iconName
1505+
}
1506+
}
1507+
1508+
// Check for .icon folders
1509+
let iconFolders = findIconFolders(in: sourcePath)
1510+
for iconFolder in iconFolders {
1511+
// For .icon folders, we can use the folder name as the icon name
1512+
return iconFolder.lastComponentWithoutExtension
1513+
}
1514+
}
1515+
}
1516+
1517+
return nil
1518+
}
1519+
1520+
/// Recursively finds all asset catalogs in a directory
1521+
/// - Parameter directory: The directory to scan
1522+
/// - Returns: Array of asset catalog paths
1523+
private func findAssetCatalogs(in directory: Path) -> [Path] {
1524+
var assetCatalogs: [Path] = []
1525+
1526+
guard directory.isDirectory else { return assetCatalogs }
1527+
1528+
let children = (try? directory.children()) ?? []
1529+
1530+
for child in children {
1531+
if child.extension == "xcassets" {
1532+
assetCatalogs.append(child)
1533+
} else if child.isDirectory {
1534+
assetCatalogs.append(contentsOf: findAssetCatalogs(in: child))
1535+
}
1536+
}
1537+
1538+
return assetCatalogs
1539+
}
1540+
1541+
/// Recursively finds all .icon folders in a directory
1542+
/// - Parameter directory: The directory to scan
1543+
/// - Returns: Array of .icon folder paths
1544+
private func findIconFolders(in directory: Path) -> [Path] {
1545+
var iconFolders: [Path] = []
1546+
1547+
guard directory.isDirectory else { return iconFolders }
1548+
1549+
let children = (try? directory.children()) ?? []
1550+
1551+
for child in children {
1552+
if child.extension == "icon" {
1553+
iconFolders.append(child)
1554+
} else if child.isDirectory {
1555+
iconFolders.append(contentsOf: findIconFolders(in: child))
1556+
}
1557+
}
1558+
1559+
return iconFolders
1560+
}
1561+
14751562
/// Make `Build Tools Plug-ins` as a dependency to the target
14761563
/// - Parameter target: ProjectTarget
14771564
/// - Returns: Elements for referencing other targets through content proxies.

Sources/XcodeGenKit/SourceGenerator.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,20 @@ class SourceGenerator {
226226
}
227227
let lastKnownFileType = lastKnownFileType ?? Xcode.fileType(path: path)
228228

229+
// Special handling for .icon folders (IconComposer)
230+
if path.extension == "icon" {
231+
let fileReference = addObject(
232+
PBXFileReference(
233+
sourceTree: sourceTree,
234+
name: fileReferenceName,
235+
lastKnownFileType: "wrapper.icon",
236+
path: fileReferencePath.string
237+
)
238+
)
239+
fileReferencesByPath[fileReferenceKey] = fileReference
240+
return fileReference
241+
}
242+
229243
if path.extension == "xcdatamodeld" {
230244
let versionedModels = (try? path.children()) ?? []
231245

@@ -462,7 +476,7 @@ class SourceGenerator {
462476
let filePaths = nonLocalizedChildren
463477
.filter {
464478
if let fileType = getFileType(path: $0) {
465-
return fileType.file
479+
return fileType.file || Xcode.isDirectoryFileWrapper(path: $0)
466480
} else {
467481
return $0.isFile || $0.isDirectory && Xcode.isDirectoryFileWrapper(path: $0)
468482
}

Sources/XcodeGenKit/XCProjExtensions.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ extension Xcode {
6363
return "wrapper.swiftcrossimport"
6464
case ("xcstrings", _):
6565
return "text.json.xcstrings"
66+
case ("icon", _):
67+
return "wrapper.icon"
6668
default:
6769
// fallback to XcodeProj defaults
6870
return Xcode.filetype(extension: fileExtension)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"info" : {
3+
"author" : "xcode",
4+
"version" : 1
5+
}
6+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
{
2+
"images" : [
3+
{
4+
"idiom" : "iphone",
5+
"size" : "20x20",
6+
"scale" : "2x"
7+
},
8+
{
9+
"idiom" : "iphone",
10+
"size" : "20x20",
11+
"scale" : "3x"
12+
},
13+
{
14+
"idiom" : "iphone",
15+
"size" : "29x29",
16+
"scale" : "2x"
17+
},
18+
{
19+
"idiom" : "iphone",
20+
"size" : "29x29",
21+
"scale" : "3x"
22+
},
23+
{
24+
"idiom" : "iphone",
25+
"size" : "40x40",
26+
"scale" : "2x"
27+
},
28+
{
29+
"idiom" : "iphone",
30+
"size" : "40x40",
31+
"scale" : "3x"
32+
},
33+
{
34+
"idiom" : "iphone",
35+
"size" : "60x60",
36+
"scale" : "2x"
37+
},
38+
{
39+
"idiom" : "iphone",
40+
"size" : "60x60",
41+
"scale" : "3x"
42+
},
43+
{
44+
"idiom" : "ipad",
45+
"size" : "20x20",
46+
"scale" : "1x"
47+
},
48+
{
49+
"idiom" : "ipad",
50+
"size" : "20x20",
51+
"scale" : "2x"
52+
},
53+
{
54+
"idiom" : "ipad",
55+
"size" : "29x29",
56+
"scale" : "1x"
57+
},
58+
{
59+
"idiom" : "ipad",
60+
"size" : "29x29",
61+
"scale" : "2x"
62+
},
63+
{
64+
"idiom" : "ipad",
65+
"size" : "40x40",
66+
"scale" : "1x"
67+
},
68+
{
69+
"idiom" : "ipad",
70+
"size" : "40x40",
71+
"scale" : "2x"
72+
},
73+
{
74+
"idiom" : "ipad",
75+
"size" : "76x76",
76+
"scale" : "1x"
77+
},
78+
{
79+
"idiom" : "ipad",
80+
"size" : "76x76",
81+
"scale" : "2x"
82+
},
83+
{
84+
"idiom" : "ipad",
85+
"size" : "83.5x83.5",
86+
"scale" : "2x"
87+
},
88+
{
89+
"idiom" : "ios-marketing",
90+
"size" : "1024x1024",
91+
"scale" : "1x"
92+
}
93+
],
94+
"info" : {
95+
"version" : 1,
96+
"author" : "xcode"
97+
}
98+
}

0 commit comments

Comments
 (0)