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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

### Fixed
- Fix `syncedFolder` source paths being relative to the spec directory instead of the project directory when they differ, which caused Xcode to treat the synced folder as empty #1636 @Ckitakishi
- Fix `destinationFilters` and `inferDestinationFiltersByPath` being ignored for `syncedFolder` sources. They are now written as `platformFiltersByRelativePath` on the synced folder's exception set #1647 @anandghegde
- Fix nested target attributes (e.g. `attributes.SystemCapabilities`) being serialized as a stringified Swift `Dictionary` description instead of a proper nested plist dictionary, which also caused non-deterministic key ordering in generated `project.pbxproj` files across runs #1639 @imadaan @sergeyospanov
- Fix `.DS_Store` and `.orig` files causing projects to be regenerated when using `--use-cache` #1622 @kimjune01
- Fix generated "Copy Bundle Resources" phases missing `dstPath`, which caused Xcode 27.2 project conversion to fail #1654 @Ckitakishi
Expand Down
2 changes: 2 additions & 0 deletions Docs/ProjectSpec.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,8 @@ A source can be provided via a string (the path) or an object of the form:
- [ ] **explicitFolders**: **[String]** - Only valid for `syncedFolder` type. A list of global patterns in the same format as `excludes` to child folders that Xcode should treat as folder references.
- [ ] **destinationFilters**: **[[Supported Destinations](#supported-destinations)]** - List of supported platform destinations the files should filter to. Defaults to all supported destinations.
- [ ] **inferDestinationFiltersByPath**: **Bool** - This is a convenience filter that helps you to filter the files if their paths match these patterns `**/<supportedDestination>/*` or `*_<supportedDestination>.swift`. Note, if you use `destinationFilters` this flag will be ignored.

For `syncedFolder` sources, `destinationFilters` and `inferDestinationFiltersByPath` are resolved from the files present when the project is generated. A file added to the folder later is built for all supported destinations until the project is regenerated.
- [ ] **createIntermediateGroups**: **Bool** - This overrides the value in [Options](#options).
- [ ] **optional**: **Bool** - Disable missing path check. Defaults to false.
- [ ] **buildPhase**: **String** - This manually sets the build phase this file or files in this directory will be added to, otherwise XcodeGen will guess based on the file extension. Note that `Info.plist` files will never be added to any build phases, no matter what this setting is. Possible values are:
Expand Down
12 changes: 8 additions & 4 deletions Sources/XcodeGenKit/PBXProjGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1492,7 +1492,7 @@ public class PBXProjGenerator {

var exceptions: Set<String> = Set(
sourceGenerator.syncedFolderExceptions(for: targetSource, at: syncedPath)
.compactMap { try? $0.relativePath(from: syncedPath).string }
.compactMap { sourceGenerator.syncedFolderExceptionKey(for: $0, in: syncedPath) }
)

for infoPlistPath in Set(infoPlistFiles.values) {
Expand All @@ -1503,15 +1503,19 @@ public class PBXProjGenerator {
}
}

guard !exceptions.isEmpty else { return }
let platformFilters = sourceGenerator.syncedFolderPlatformFilters(for: targetSource, at: syncedPath)
.filter { !exceptions.contains($0.key) }

guard !exceptions.isEmpty || !platformFilters.isEmpty else { return }

let exceptionSet = PBXFileSystemSynchronizedBuildFileExceptionSet(
target: targetObject,
membershipExceptions: exceptions.sorted(),
membershipExceptions: exceptions.isEmpty ? nil : exceptions.sorted(),
publicHeaders: nil,
privateHeaders: nil,
additionalCompilerFlagsByRelativePath: nil,
attributesByRelativePath: nil
attributesByRelativePath: nil,
platformFiltersByRelativePath: platformFilters.isEmpty ? nil : platformFilters
)
addObject(exceptionSet)
syncedGroup.exceptions = (syncedGroup.exceptions ?? []) + [exceptionSet]
Expand Down
42 changes: 42 additions & 0 deletions Sources/XcodeGenKit/SourceGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,48 @@ class SourceGenerator {
return exceptions
}

/// Returns the key Xcode uses for a path in a synced folder's exception set.
/// Localized files are keyed by their variant group, e.g. `/Localized: Resources/Localizable.strings`,
/// since Xcode ignores per-language paths like `Resources/en.lproj/Localizable.strings`.
func syncedFolderExceptionKey(for path: Path, in syncedPath: Path) -> String? {
guard let relativePath = try? path.relativePath(from: syncedPath).string else { return nil }

var components = relativePath.split(separator: "/")
guard let localizedIndex = components.firstIndex(where: { $0.hasSuffix(".lproj") }) else { return relativePath }
components.remove(at: localizedIndex)
return "/Localized: \(components.joined(separator: "/"))"
}

/// Returns the platform filters for each item in a synced folder, keyed by `syncedFolderExceptionKey`.
/// Xcode ignores filters on plain folders, so they are expanded into their files. Explicit folders are
/// a single item to Xcode, so they get one key and their contents are ignored.
func syncedFolderPlatformFilters(for targetSource: TargetSource, at syncedPath: Path) -> [String: [String]] {
guard targetSource.destinationFilters?.isEmpty == false || targetSource.inferDestinationFiltersByPath == true else {
return [:]
}

let explicitFolders = Set(resolveExplicitFolders(targetSource: targetSource))
var platformFilters: [String: [String]] = [:]

func findFiles(in path: Path) {
guard let children = try? path.children() else { return }

for child in children where !child.lastComponent.hasPrefix(".") {
let isExplicitFolder = (try? child.relativePath(from: syncedPath).string).map(explicitFolders.contains) ?? false

if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) && !isExplicitFolder {
findFiles(in: child)
} else if let filters = makeDestinationFilters(for: child, with: targetSource.destinationFilters, or: targetSource.inferDestinationFiltersByPath),
let key = syncedFolderExceptionKey(for: child, in: syncedPath) {
platformFilters[key] = filters
}
}
}

findFiles(in: syncedPath)
return platformFilters
}

/// Collects all the excluded paths within the targetSource
private func getSourceMatches(targetSource: TargetSource, patterns: [String]) -> Set<Path> {
let rootSourcePath = project.basePath + targetSource.path
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
let syncedPlatformName = "iOS"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
let syncedPlatformName = "tvOS"
29 changes: 29 additions & 0 deletions Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,18 @@
);
target = 0867B0DACEF28C11442DE8F7 /* App_iOS */;
};
9A8E6A7891C068692E5463B2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
platformFiltersByRelativePath = {
SyncedFile_iOS.swift = (
ios,
);
SyncedFile_tvOS.swift = (
tvos,
);
};
target = C0570E2FB50D830D8D423396 /* App_supportedDestinations */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
Expand Down Expand Up @@ -870,6 +882,19 @@
path = SyncedFolder;
sourceTree = "<group>";
};
E55B4EB82638DF874A0308EC /* SyncedSources */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
9A8E6A7891C068692E5463B2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
explicitFileTypes = {
};
explicitFolders = (
);
name = SyncedSources;
path = SyncedSources;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -1449,6 +1474,7 @@
children = (
85FEBB7D2103B020423407A2 /* Sources */,
69C61547C081D04364A5DE42 /* Storyboards */,
E55B4EB82638DF874A0308EC /* SyncedSources */,
);
path = App_supportedDestinations;
sourceTree = "<group>";
Expand Down Expand Up @@ -2282,6 +2308,9 @@
);
dependencies = (
);
fileSystemSynchronizedGroups = (
E55B4EB82638DF874A0308EC /* SyncedSources */,
);
name = App_supportedDestinations;
packageProductDependencies = (
C7F9B7EDE85527EFEA85D46D /* Swinject */,
Expand Down
4 changes: 4 additions & 0 deletions Tests/Fixtures/TestProject/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ targets:
- path: App_supportedDestinations/Storyboards
group: App_supportedDestinations
destinationFilters: [iOS]
- path: App_supportedDestinations/SyncedSources
group: App_supportedDestinations
type: syncedFolder
inferDestinationFiltersByPath: true
dependencies:
- package: Swinject
product: Swinject
Expand Down
112 changes: 112 additions & 0 deletions Tests/XcodeGenKitTests/SourceGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,118 @@ class SourceGeneratorTests: XCTestCase {
try expect(exceptions.contains("included.swift")) == false
}

$0.it("adds destinationFilters as platform filters for synced folder files") {
let directories = """
Sources:
- a.swift
- excluded.swift
- Nested:
- b.swift
"""
try createDirectories(directories)

let source = TargetSource(path: "Sources", excludes: ["excluded.swift"], type: .syncedFolder, destinationFilters: [.iOS])
let target = Target(name: "Test", type: .application, platform: .auto, supportedDestinations: [.iOS, .tvOS], sources: [source])
let project = Project(basePath: directoryPath, name: "Test", targets: [target])

let pbxProj = try project.generatePbxProj()
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
let syncedFolder = try unwrap(syncedFolders.first)

let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
try expect(exceptionSet.membershipExceptions) == ["excluded.swift"]
try expect(exceptionSet.platformFiltersByRelativePath) == [
"a.swift": ["ios"],
"Nested/b.swift": ["ios"],
]
}

$0.it("infers platform filters by path for synced folder files") {
let directories = """
Sources:
- common.swift
- File_tvOS.swift
- iOS:
- a.swift
"""
try createDirectories(directories)

let source = TargetSource(path: "Sources", type: .syncedFolder, inferDestinationFiltersByPath: true)
let target = Target(name: "Test", type: .application, platform: .auto, supportedDestinations: [.iOS, .tvOS], sources: [source])
let project = Project(basePath: directoryPath, name: "Test", targets: [target])

let pbxProj = try project.generatePbxProj()
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
let syncedFolder = try unwrap(syncedFolders.first)

let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
try expect(exceptionSet.membershipExceptions).to.beNil()
try expect(exceptionSet.platformFiltersByRelativePath) == [
"File_tvOS.swift": ["tvos"],
"iOS/a.swift": ["ios"],
]
}

$0.it("adds platform filters for explicit folders as a single item") {
let directories = """
Sources:
- a.swift
- Bundle:
- data.txt
- Nested:
- more.txt
"""
try createDirectories(directories)

let source = TargetSource(path: "Sources", explicitFolders: ["Bundle"], type: .syncedFolder, destinationFilters: [.iOS])
let target = Target(name: "Test", type: .application, platform: .auto, supportedDestinations: [.iOS, .macOS], sources: [source])
let project = Project(basePath: directoryPath, name: "Test", targets: [target])

let pbxProj = try project.generatePbxProj()
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
let syncedFolder = try unwrap(syncedFolders.first)

let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
try expect(exceptionSet.platformFiltersByRelativePath) == [
"a.swift": ["ios"],
"Bundle": ["ios"],
]
}

$0.it("adds platform filters for localized files in the variant group form") {
let directories = """
Sources:
- a.swift
- excluded.swift
- Resources:
- en.lproj:
- Localizable.strings
- Excluded.strings
- de.lproj:
- Localizable.strings
- Excluded.strings
"""
try createDirectories(directories)

let source = TargetSource(path: "Sources", excludes: ["excluded.swift", "Resources/*.lproj/Excluded.strings"], type: .syncedFolder, destinationFilters: [.iOS])
let target = Target(name: "Test", type: .application, platform: .auto, supportedDestinations: [.iOS, .macOS], sources: [source])
let project = Project(basePath: directoryPath, name: "Test", targets: [target])

let pbxProj = try project.generatePbxProj()
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
let syncedFolder = try unwrap(syncedFolders.first)

let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
try expect(exceptionSet.membershipExceptions) == [
"/Localized: Resources/Excluded.strings",
"excluded.swift",
]
try expect(exceptionSet.platformFiltersByRelativePath) == [
"a.swift": ["ios"],
"/Localized: Resources/Localizable.strings": ["ios"],
]
}

$0.it("merges explicitFolders for synced folders across targets") {
let directories = """
Sources:
Expand Down
Loading