v6/site/posts/2022-02-23-swift-package-fr...

6.1 KiB

title = "Swift Packages and Frameworks"
tags = ["swift"]
date = "2022-02-23 21:23:42 -0400"
slug = "swift-package-framework"

Tusker is divided up into two main parts: the app target itself and a separate framework which encapsulates everything that deals with the Mastodon API. I recently added a Swift Package to the app for uninteresting reasons. But, because the package is used both by the framework as well as the app itself, this caused a surprising number of problems.

Adding the package to the app went perfectly well. I added it in Xcode, set the framework and app to depend on it, and then got to building. Everything worked and ran perfectly normally. But, when the time came to publish a new build to TestFlight, the issues started appearing.

Upon uploading, App Store Connect returned an error telling me that the framework for the Swift Package I'd added wasn't code signed. This was surprising for a couple reasons: first, Swift Packages are generally statically linked (meaning they're compiled directly into the binary that uses them) rather than shipping as separate, dynamically-linked frameworks. Second, my Xcode project is setup to automatically handle code signing. Why would it be skipping the framework?

The answer is that it wasn't. The framework for the framework for the package was getting signed perfectly fine. Just not the right one.

It seems having multiple targets that depend on a Swift package causes Xcode to dynamically link it. As with other frameworks, the framework for the package gets built and embedded in the Frameworks/ folder of the app that depends on it.

But the app isn't the only thing that depends on it. The package framework was also getting embedded inside my framework before it was in turn being embedded in the app.

Tusker.app
└── Frameworks
   ├── Pachyderm.framework
   │  └── Frameworks
   │      └── WebURL.framework
   └── WebURL.framework

Xcode was properly signing the app's frameworks, it was not signing the nested frameworks. Hence the App Store Connect error.

"No problem," I naively thought, "I'll just add a Run Script build phase to codesign the nested one myself." Yes problem. Turns out App Store Connect entirely rejects nested frameworks, even if they're correctly signed.

So, I changed the script to entirely delete the nested Frameworks directory (this is fine at runtime because the runpath search paths includes the top-level Frameworks dir), which finally convinced App Store Connect to accept my build.

if [ "$(ls "$BUILT_PRODUCTS_DIR/Tusker.app/Frameworks/Pachyderm.framework/Frameworks/")" -ne "WebURL.framework" ]; then
	echo "error: unexpected framework inside Pachyderm, make sure it's embedded directly in the app"
    exit 1
fi
rm -rf "$BUILT_PRODUCTS_DIR/Tusker.app/Frameworks/Pachyderm.framework/Frameworks/"

You might think that's where this story ends, but, sadly, it's not. I noticed when downloading the new TestFlight build that the app was up to 25 megabytes in size. That might not sound like much, but the previous version was around 5MB and I hadn't added anything that should have caused a quintupling in size.

Looking at the archive and comparing it to a previous one, it was clear that almost all of the increase was coming from the package framework.

I'd previously tried out this Swift package in a small test app, so I went back to compare its size to the new version of Tusker. The test project was nowhere near as big—just two megabytes.

The sole relevant difference between the two projects, as far as I can tell, is whether the Swift package is linked statically or dynamically. My best guess for the app size difference is that when the package is linked dynamically, dead code elimination can't happen across module boundaries. Anything declared as public—or used by something public—must be kept, because you don't know which parts of it the ultimate consumer needs. When linked statically, public-but-unused code can be stripped, which can result in significant size savings if you're not using the entire API surface of a package.

Addendum

I tried two (2) methods for getting Xcode to statically link everything, in hopes of bringing the binary size back down.

The first method was changing my own framework from being, well, a framework to a Swift package. In theory, this should mean that everything gets statically linked into just the app binary. This should be straightforward, but I want the framework/package code to live in the same Git repo alongside the app, as no one else uses it and versioning it separately is a pain. Xcode... does not like this.

You can create a new package in the same repo as an existing project from Xcode no problem. While doing so, you can add it to the existing xcworkspace without objection. But when you try to add the package as a dependency of the app, it just fails silently.

I can click the "Add Local" button in the add package window, I can select the package directory, and click the add button. And then nothing happens. The package doesn't show up in the "Swift Packages" tab of the project nor under the dependencies of the target to which I added it. So, compilation just fails because the module is missing.

After abandoning that idea, the other, similarly unsuccessful, tactic I tried was encapsulating all of the usages of the package's types within my framework in an opaque type and removing the dependency on the package from the app target. This was in the hopes that the package would be statically linked into the framework and have all the unnecessary bits stripped.

That did not work. I don't know why. There seems to be very little visibility (read: none at all) into how Xcode chooses to static versus dynamic linking for Swift packages.

That's where I gave up, so if you have any better ideas, please let me know. At the end of the day, I don't have the energy to spend more time fighting Xcode over 20 megabytes. Oh well. I should probably throw a report into the void that is Feedback Assistant.

Update: As of April 2022, I've resolved this issue.