Analyze .ipa Bundles with Rosalind
In the 8th Dev Conversations episode, with Pedro and Marek, one of the discussed topics was their work on multiple features from Tuist, that when extracted to public Swift packages, can also be extremely useful to the community . We’ve already covered XcodeProj (which allows you to manipulate Xcode projects), Noora (a design system for CLI output) and Command (an async-await API for spinnig up subprocesses from Swift).
Recently, Tuist released another package that is a great addition to the Swift ecosystem: Rosalind. This is a package that allows your tools to analyze .ipa bundles and extract information about them, such as the app download size, install size, and many other insightful details about your app.
In this post, we’ll build a small CLI tool that uses Rosalynd to analyze the .ipa bundle of an open source project, IceCubes app, an open source Mastodon client.
Setting up the Sample Project
The goal of this article is to build an executable that, given a path to an .ipa bundle, will print out some insights about the app. Such tool (or similar) could add value to your CI pipelines, by comparing the size of the app between different versions, or for example in Pull Requests. In the same way, you could create a macOS tool that allows you to analyze the size of your app before and after a release.
For the sample project, we’ve prepared a release build of the IceCubes app.
Creating the CLI Tool
To create a new Swift tool with the argument parser, you can use the following command:
swift package init --type tool
This command will create a new Swift package with an executable target, which depends on the ArgumentParser package. Open the Package.swift on Xcode, or the project in your text editor of choice. Add Rosalind as both package and target dependencies:
dependencies: [
// argument parser...
.package(url: "https://github.com/tuist/Rosalind.git", from: "0.5.38")
],
targets: [
.executableTarget(
name: "bundle-analyzer",
dependencies: [
// argument parser...
.product(name: "Rosalind", package: "rosalind")
]
),
]
Configuring the Path Argument
The tool will take a single argument: the path to the .ipa bundle. We’ll use the Option
type from the ArgumentParser
package to define it as a named argument. In the entrypoint of the tool, being by changing the conforming type of the struct from ParsableCommand
to AsyncParsableCommand
. Then, add the @Option
property, and also declare the main
function as async
. Leave the function empty for now:
@main
struct BundleAnalyzer: AsyncParsableCommand {
@Option var path: String? = nil
mutating func run() async throws {}
}
The path
property is not optional, and the argument parser will throw an error if no value is provided for it. It’ll be your responsibility to check that this path is a valid one, and convert it to an AbsolutePath
, a type that Rosalind uses to represent file paths.
To do so, define a function that performs this validation:
func ipaBundlePath() throws -> AbsolutePath {
// 1
let workingDirectory = try AbsolutePath(validating: FileManager.default.currentDirectoryPath)
// 2
guard let validatedPath = try? AbsolutePath(
validating: path,
relativeTo: workingDirectory
), validatedPath.extension == "ipa" else {
// 3
throw ValidationError("The provided path is not an IPA file.")
}
// 4
return validatedPath
}
Here’s what this function does:
- Get the current working directory, to support relative paths by the user.
- Validate the provided path, by converting it to an
AbsolutePath
and providing the current working directory as the base. Also, check that the extension is.ipa
. - If the path is not valid, throw a
ValidationError
. - If everything is fine, return the validated path.
Analyzing the Bundle
Now that you have a way to get an AbsolutePath
to the .ipa bundle, you can use this function in the run
function and pass it along to Rosalind. Don’t forget to import the Rosalind package first:
mutating func run() async throws {
// 1 Get the path from the function you just defined
let path = try ipaBundlePath()
let rosalind = Rosalind()
// 2 Print a message to the user, as the analysis might take a while
print("Analyzing bundle at (path)")
// 3 Analyze the bundle
let report = try await rosalind.analyzeAppBundle(at: path)
}
Now things are starting to get interesting:
- Get the path from the function you just defined, and initialize a Rosalind instance.
- Print a message to the user with the path to the bundle.
- Call the async
analyzeAppBundle
function from Rosalind, and store the value it returns in a property.
Analyzing the Results
With the report struct in hand, it’s time to start analyzing and printing the results.
One of the most important types in Rosalind is the AppBundleArtifact
. This struct contains the information of every item inside the .ipa bundle, such as its path (and therefore name), size, type, and its children. For example, .xcassets files are converted to .car files, which is a compiled version of all the assets.
We’ll start by defining a function that receives an AppBundleArtifact
, an indent (to get the level of nesting), and an inout
array of messages that will be printed to the user later on. This function will be recursive, meaning that it will call itself for each child of the artifact:
private func analyzeArtifact(
_ artifact: AppBundleArtifact,
indent: Int,
contents: inout [(String, Int)]
) {
// 1
let name = artifact.path.components(separatedBy: "/").last ?? ""
contents.append(("(name): (artifact.size.formattedSize)", indent))
// 2
guard artifact.shouldPrintChildren else { return }
// 3
for child in artifact.sortedChildren {
analyzeArtifact(child, indent: indent + 1, contents: &contents)
}
}
Here’s what this function does:
- Get the name of the artifact, and add a message to the contents array, along with the current indent.
- For some types of artifacts, you might not want to print every child. If that’s the case, skip the children analysis.
- Call the function recursively for each child.
If you try to compile the tool now, you’ll get 3 errors, because there are 3 extension properties that will help this function:
The first extension will sort the child artifacts by size, so that the largest ones are printed first. The second extension will decide which types of artifacts we want to skip. In this case, we’ll skip .car
files (assets) which might contain way too many files, and .lproj
files (localizations), which always contain a strings file.
extension AppBundleArtifact {
var sortedChildren: [AppBundleArtifact] {
children?.sorted { $0.size > $1.size } ?? []
}
var shouldPrintChildren: Bool {
if path.hasSuffix(".car") || path.hasSuffix(".lproj") {
return false
}
return true
}
}
The other extension formats an integer size of an artifact, in a human readable format:
extension Int {
var formattedSize: String {
let units = ["B", "KB", "MB", "GB", "TB"]
var size = Double(self)
var unitIndex = 0
while size >= 1024 && unitIndex < units.count - 1 {
size /= 1024
unitIndex += 1
}
return String(format: "%.1f", size) + units[unitIndex]
}
}
Analyzing the Results
In the last line of the run
function, add a call to a new function, that will analyze and print the report results:
analyzeAndPrintReport(report)
Now, implement this function. Start by printing some basic information about the bundle:
private func analyzeAndPrintReport(_ report: AppBundleReport) {
print("(report.name) ((report.version)) bundle report is ready:")
if let downloadSize = report.downloadSize {
print("Download size: (downloadSize.formattedSize)")
}
print("Install size: (report.installSize.formattedSize)")
print("Total artifacts: (report.artifacts.count)")
print("") // an empty line between artifacts count and their contents
// more to come
}
Then, add the following code to use the analyzeArtifact
function you defined in the previous section:
// 1
var contents: [(String, Int)] = []
// 2
let sortedArtifacts = report.artifacts.sorted(by: { $0.size > $1.size })
// 3
for artifact in sortedArtifacts {
analyzeArtifact(artifact, indent: 0, contents: &contents)
}
// 4
printArtifacts(contents)
Here’s what this code does:
- Create an empty array to store the contents of the report, which is the type the
analyzeArtifact
function expects. - Sort the artifacts by size, so that the largest ones are printed first.
- Call the
analyzeArtifact
function for each artifact, starting with an indent of 0. - Print the contents of the report, which is a function you’ll implement next.
Printing the Results
Finally, implement the printArtifacts
function. This function will receive the analyzed contents of the report, and print them to the user, taking into account the indent of each artifact:
private func printArtifacts(_ messages: [(text: String, indent: Int)]) {
for message in messages {
let indentString = String(repeating: " ", count: message.indent * 4) + "∙"
print(indentString, message.text)
}
}
And with all set, it’s time to run the tool!
swift run bundle-analyzer --path ./IceCubes.ipa
And here’s the output:

Explore Further
Rosalind is another great addition to the Swift ecosystem, and it can help you building your own tools!
You can find the sample project on GitHub.
You can also use Tuist itself - even on non-Tuist generated projects, to export a JSON format of the analysis:
tuist inspect bundle YouBundlePath.ipa --json
If you have any questions or suggestions, feel free to reach out on Mastodon or X.
See you at the next post. Have a good one!