Skip to content

build: Add XCFramework binary distribution support#266

Merged
cameroncooke merged 3 commits into
mainfrom
cameroncooke/eme-1168-xcframework-binary-distribution
Jun 8, 2026
Merged

build: Add XCFramework binary distribution support#266
cameroncooke merged 3 commits into
mainfrom
cameroncooke/eme-1168-xcframework-binary-distribution

Conversation

@cameroncooke

Copy link
Copy Markdown
Contributor

Adds XCFramework binary distribution support for SnapshotPreviews while keeping the standard Swift Package integration source-first.

The release build now produces the full manually consumable framework set under XCFrameworks/, packages those artifacts for GitHub releases, and includes a separate example project that integrates the generated frameworks without Swift Package Manager.

Swift Package vs binary distribution

Package.swift remains the public source-package manifest. SwiftPM is allowed to choose linkage for regular products, while Snapshotting stays explicitly dynamic because it is the inserted/runtime dylib product.

XCFrameworkPackage.swift is a build-only manifest used by build.sh. It forces the currently built product to dynamic for binary packaging and wires already-built SnapshotPreviews dependencies back in as binary targets, so shared support modules are dynamically linked instead of being repeatedly embedded into parent frameworks.

Vended artifact layout

Generated binary artifacts are written to XCFrameworks/:

  • SnapshotSharedModels.xcframework
  • SnapshotPreviewsCore.xcframework
  • SnapshotPreferences.xcframework
  • PreviewGallery.xcframework
  • SnapshottingTests.xcframework
  • Snapshotting.xcframework
  • PreviewsSupport.xcframework

PreviewsSupport remains checked in at PreviewsSupport/PreviewsSupport.xcframework for SwiftPM usage. The distribution build copies it into XCFrameworks/ so binary consumers and release assets use one artifact folder.

Dynamic dependency model

Manual XCFramework integration does not get SwiftPM dependency resolution automatically, so consumers must link and embed the documented framework closure for each target.

The generated frameworks keep shared SnapshotPreviews dependencies as dynamic framework dependencies. This avoids duplicating shared support modules across frameworks when multiple SnapshotPreviews products are loaded in the same process.

Binary example project

Examples/DemoApp remains the normal local Swift Package example.

Examples/DemoApp-XCFrameworks is the manual binary integration example. It shares source files from ../DemoApp/..., links against ../../XCFrameworks/*.xcframework, and includes a small wrapper script:

cd Examples/DemoApp-XCFrameworks
./generate-xcframeworks.sh
open DemoApp-XCFrameworks.xcodeproj

Shared demo source that depends on optional external packages uses canImport, so the Swift Package example can keep accessibility snapshot rendering while the XCFramework example builds without that external package.

Manual integration matrix

For an app target using per-preview rendering preferences:

  • SnapshotPreferences.xcframework
  • SnapshotSharedModels.xcframework

For an app target using the gallery UI:

  • PreviewGallery.xcframework
  • SnapshotPreviewsCore.xcframework
  • SnapshotPreferences.xcframework
  • SnapshotSharedModels.xcframework
  • PreviewsSupport.xcframework

For an XCTest snapshot/layout target:

  • SnapshottingTests.xcframework
  • SnapshotPreviewsCore.xcframework
  • SnapshotSharedModels.xcframework
  • PreviewsSupport.xcframework

For the inserted-dylib/accessibility runtime flow, also include:

  • Snapshotting.xcframework

Refs EME-1168

Generate the vendored SnapshotPreviews frameworks with a Bash-based Xcode archive flow and a build-only package manifest that forces dynamic products for packaging.

Add a binary-framework DemoApp example, document the manual integration requirements, and update release packaging to publish the generated XCFramework artifacts from the XCFrameworks directory.

Co-Authored-By: Codex <noreply@openai.com>
@linear-code

linear-code Bot commented Jun 4, 2026

Copy link
Copy Markdown

EME-1168

@NicoHinderling NicoHinderling left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hopefully @noahsmartin can take a look as well

Comment thread .github/workflows/release.yaml Outdated
run: bash build.sh
- name: Zip xcframeworks
run: |
rm -f *.xcframework.zip

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why deleting?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll fix that. Bit pointless as the runner will be torn down.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@cursor fix

@cursor cursor Bot Jun 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Done. I removed the unnecessary rm -f *.xcframework.zip line from the release workflow. You were right that it's pointless since CI runners are ephemeral and start fresh each time anyway.

The change has been committed and pushed to your branch.

Open in Web Open in Cursor 

@jamieQ jamieQ left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks reasonable to me!

Comment thread build.sh
Comment thread build.sh
sanitize_swift_interfaces "$modules_path" "$framework"
}

sanitize_swift_interfaces() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's the motiviation for this logic? Seems it's not new, but just curious

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I actually had to ask Claude Code this as I didn't have context from the original implementation:

This is carried over from the existing XCFramework build flow. The motivation is to make the emitted .swiftinterface files importable by binary consumers.

The generated interfaces can include private interfaces, NSInvocation references from XCTest/ObjC interop, awkward XCTest. qualification, and self-module-qualified names such as PreviewGallery.PreviewGallery. Those either are not useful to consumers or can break module import for the vendored frameworks.

So this is not new behavior conceptually; it preserves the existing interface cleanup while expanding the framework set/platform matrix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@noahsmartin might know

The runner is ephemeral and starts fresh each time, so cleaning up *.xcframework.zip files before zipping is unnecessary.

Co-authored-by: Cameron Cooke <web@cameroncooke.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Snapshotting interfaces skip sanitization
    • Modified copy_swift_module to sanitize archived Modules folder even when source swiftmodule is missing, ensuring Swift interfaces from dependencies are properly cleaned.

Create PR

Or push these changes by commenting:

@cursor push 647219353e
Preview (647219353e)
diff --git a/build.sh b/build.sh
--- a/build.sh
+++ b/build.sh
@@ -96,16 +96,6 @@
     configuration="Release"
   fi
 
-  local source_module_path="$DERIVED_DATA_DIR/$framework/Build/Intermediates.noindex/ArchiveIntermediates/$framework/BuildProductsPath/$configuration/$framework.swiftmodule"
-  if [ ! -d "$source_module_path" ]; then
-    if requires_swift_module "$framework"; then
-      echo "Missing Swift module output for $framework at $source_module_path" >&2
-      exit 1
-    fi
-
-    return
-  fi
-
   local framework_path="$archive_path/Products/Library/Frameworks/$framework.framework"
   local modules_path="$framework_path/Modules"
 
@@ -117,6 +107,19 @@
     ln -s "Versions/Current/Modules" "$framework_path/Modules"
   fi
 
+  local source_module_path="$DERIVED_DATA_DIR/$framework/Build/Intermediates.noindex/ArchiveIntermediates/$framework/BuildProductsPath/$configuration/$framework.swiftmodule"
+  if [ ! -d "$source_module_path" ]; then
+    if requires_swift_module "$framework"; then
+      echo "Missing Swift module output for $framework at $source_module_path" >&2
+      exit 1
+    fi
+
+    if [ -d "$modules_path" ]; then
+      sanitize_swift_interfaces "$modules_path" "$framework"
+    fi
+    return
+  fi
+
   mkdir -p "$modules_path"
   if [ -d "$modules_path/$framework.swiftmodule" ]; then
     rm -r "$modules_path/$framework.swiftmodule"

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 5df5671. Configure here.

Comment thread build.sh Outdated
… module is missing

Ensure sanitize_swift_interfaces runs on Modules folder in the archived
framework when the source swiftmodule path is absent but the archived
Modules directory exists (e.g., from Swift dependencies like SnapshottingSwift).

Applied via @cursor push command
Comment thread build.sh
Comment on lines +183 to +187
for platform in "${PLATFORMS[@]}"; do
sdk="${platform%%|*}"
destination="${platform#*|}"
build_framework_archive "$framework" "$sdk" "$destination"
done

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The build script will fail when compiling SnapshottingTests for watchOS because EMGInvocationCreator.mm uses NSInvocation, which is unavailable on that platform.
Severity: CRITICAL

Suggested Fix

Wrap the usage of NSInvocation in EMGInvocationCreator.mm with an #if !TARGET_OS_WATCH preprocessor directive to exclude it from watchOS builds. Alternatively, exclude the SnapshottingTestsObjc source from the watchOS target in the package manifest files (Package.swift, XCFrameworkPackage.swift).

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: build.sh#L183-L187

Potential issue: The `build.sh` script now attempts to build and archive
`SnapshottingTests` for watchOS and watchOS Simulator. However, a dependency,
`EMGInvocationCreator.mm`, unconditionally uses `NSInvocation` in its `create:` method.
Since `NSInvocation` is not available in the watchOS SDK, the compilation will fail.
Because the build script has `set -euo pipefail` enabled, this compilation error will
cause the entire script to abort, preventing the generation of any XCFrameworks. This
issue was not present before as builds were only performed for iOS targets.

Did we get this right? 👍 / 👎 to inform future reviews.

@cameroncooke cameroncooke merged commit 2aebdc0 into main Jun 8, 2026
19 checks passed
@cameroncooke cameroncooke deleted the cameroncooke/eme-1168-xcframework-binary-distribution branch June 8, 2026 15:45
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.

4 participants