Skip to content

Support destinationFilters for synced folders - #1647

Open
anandghegde wants to merge 5 commits into
yonaskolb:masterfrom
anandghegde:synced-folder-destination-filters
Open

anandghegde wants to merge 5 commits into
yonaskolb:masterfrom
anandghegde:synced-folder-destination-filters

Conversation

@anandghegde

@anandghegde anandghegde commented Sep 14, 2026 •

Copy link
Copy Markdown

Description

destinationFilters and inferDestinationFiltersByPath were ignored for type: syncedFolder sources. Regular sources get the filters on each PBXBuildFile, but synced folders have no build files, so every file in the folder was compiled for every supported destination.

Xcode stores per-file platform filters for synced folders in platformFiltersByRelativePath on the PBXFileSystemSynchronizedBuildFileExceptionSet, which XcodeProj already supports. This PR fills it in:

  • SourceGenerator.syncedFolderPlatformFilters walks the synced folder (same traversal as syncedFolderExceptions: recurse into plain directories, treat file wrappers like .xcassets as files, skip hidden files) and uses the existing makeDestinationFilters for each file. Xcode only honors file paths here, not folder paths, so folders are expanded into files.
  • configureMembershipExceptions adds those filters to the exception set, leaving out files that are already membership exceptions. An exception set is now also created when there are only platform filters.
targets:
  App:
    type: application
    supportedDestinations: [iOS, visionOS]
    sources:
      - path: iOSSources
        type: syncedFolder
        destinationFilters: [iOS]
platformFiltersByRelativePath = {
    AppDelegate.swift = (
        ios,
    );
};

Note: the example in the issue lists the same synced folder path twice in one target. That already doesn't work on master, separately from filters: only the first source's settings are used, and the group is added to fileSystemSynchronizedGroups twice. I left that out to keep this PR small. Using separate folders, or one folder with inferDestinationFiltersByPath, works with this change.

Testing

  • Added two tests in SourceGeneratorTests (destinationFilters with excludes and a nested folder, and inferDestinationFiltersByPath). Both failed before the change.
  • Added a synced folder with inferDestinationFiltersByPath to App_supportedDestinations in the TestProject fixture and regenerated it. It has SyncedFile_iOS.swift and SyncedFile_tvOS.swift, which declare the same symbol, so the target only builds if the filters are applied.
  • swift test passes, and scripts/gen-fixtures.sh + scripts/diff-fixtures.sh show no changes after the commit.
  • Built App_supportedDestinations with Xcode 26.6 for the iOS Simulator and the tvOS Simulator SDK. Each build compiled only its own SyncedFile_*.swift. With platformFiltersByRelativePath removed from the generated project by hand, the iOS build fails with invalid redeclaration of 'syncedPlatformName'.
  • Generated a sample iOS + visionOS project with destinationFilters on synced folders (like the example above) and built it for iphonesimulator, xros and xrsimulator. Each build compiled only the files for its platform plus the unfiltered shared folder.

Related to #1646. This doesn't fix the example in that issue, because it lists the same synced folder twice in one target (see the note above).

@yonaskolb yonaskolb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks @anandghegde.

The approach matches what Xcode reads. Built with Xcode 27.0 for iOS Simulator and macOS, files and resources filtered with destinationFilters and inferDestinationFiltersByPath only compile/copy on their platform, asset catalogs are filtered correctly, excluded files stay out of the filters, and output is deterministic. Expanding folders to individual files is also necessary, as Xcode ignores filter keys on plain folders. A few cases still need handling:

1. Explicit folders aren't filtered

The walk in SourceGenerator.syncedFolderPlatformFilters recurses into folders listed in explicitFolders and writes keys for the files inside them (e.g. Bundle/data.txt). Xcode treats an explicit folder as a single item, so those keys are ignored and the folder is still copied on every platform. With destinationFilters: [iOS] and explicitFolders: [Bundle], the macOS build still copies Bundle/data.txt. A key for the folder itself (Bundle = (ios, )) is honoured. Suggested fix: match the source's explicitFolders during the walk, emit the folder's relative path as the key and don't recurse into it.

2. Localized files aren't filtered

Files in *.lproj folders get keys like en.lproj/Localizable.strings and de.lproj/Localizable.strings, which Xcode ignores, so both languages are still copied on the filtered-out platform. Xcode's key for a localized file is the variant group form, "/Localized: Localizable.strings", and a single such key filters all languages correctly. #1633 uses the same /Localized: form for membership exceptions, so a shared helper that converts a synced relative path to Xcode's key (collapsing *.lproj/X to /Localized: X and de-duplicating languages) and is used for both membershipExceptions and platformFiltersByRelativePath would keep the two consistent. Without it, the .filter { !exceptions.contains($0.key) } de-duplication in configureMembershipExceptions will also stop matching for localized files once #1633 lands.

3. Test assertion that doesn't run

In SourceGeneratorTests.swift, try expect(exceptionSet.membershipExceptions) == nil only produces compiler warnings ("result of operator '==' is unused" / "comparing non-optional value ... to 'nil' always returns false"), so nothing is asserted. try expect(exceptionSet.membershipExceptions).to.beNil() works, as used in SchemeGeneratorTests.

4. The #1646 example is still broken

The example in #1646 lists Sources twice in one target with different includes/excludes/filters. With this PR it generates two identical exception sets for the target, puts VisionOSMain.swift in membershipExceptions in both (so the visionOS build has no @main), and lists the synced group twice in fileSystemSynchronizedGroups. That duplication predates this PR, so it doesn't need to be solved here, but the PR shouldn't close #1646. Merging multiple sources for the same synced path, or a validation error for duplicate synced paths in a target, could be a follow-up.

5. Docs

Docs/ProjectSpec.md isn't updated. For synced folders the filters are resolved from the files present at generation time, so a file added to a filtered folder later is built for all platforms until the project is regenerated (confirmed: a UIKit file added to an iOS-only folder breaks the macOS build until regeneration). That differs from the usual synced folder behaviour and is worth a sentence on destinationFilters and inferDestinationFiltersByPath.

6. inferDestinationFiltersByPath matches the absolute path (existing issue)

makeDestinationFilters matches the regexes against the absolute path, so a project inside any directory named iOS (e.g. ~/Code/iOS/MyApp) gets every file filtered to ios. This affects regular sources too and isn't introduced here, but it now also applies to synced folders. Matching against the path relative to the source root would fix it, here or in a follow-up.

Also note CI's Tests/Fixtures/TestProject/build.sh only builds App_iOS Test and App_macOS, so the new App_supportedDestinations/SyncedSources fixture isn't built in CI.

Xcode treats an explicit folder as a single item, so give it one platform
filter key instead of keys for the files inside it. Localized files are
keyed by their variant group ("/Localized: Resources/Localizable.strings"),
which Xcode honors for every language, through a helper that the
membership exceptions now share.

Also assert that membershipExceptions is nil instead of comparing it.
@anandghegde

Copy link
Copy Markdown
Author

Thanks for testing this so thoroughly.

  1. Explicit folders: the walk now stops at a folder listed in explicitFolders and writes one key for the folder.
  2. Localized files: syncedFolderExceptionKey turns Resources/en.lproj/X into /Localized: Resources/X, using the same form as Fix localized files leaking into synced folder targets #1633. membershipExceptions and platformFiltersByRelativePath both use it, so the de-duplication also works for localized files. Whichever of this and Fix localized files leaking into synced folder targets #1633 lands second can drop its copy of the mapping.
  3. The assertion is now .to.beNil().
  4. I changed the description so it no longer suggests this fixes destinationFilters are silently ignored for syncedFolder sources (no platformFiltersByRelativePath emitted) #1646.
  5. Added a sentence under inferDestinationFiltersByPath in ProjectSpec.md.
  6. I'd rather do this in a follow-up because it also changes how regular sources are matched.

There are new tests for 1 and 2. The App_supportedDestinations fixture is unchanged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants