5 years ago

Abstracting Info.plist files

If you have worked with Xcode projects before, you might know what Info.plist files are. For those of you who are not familiar with them, they are plain xml files with key-value pairs that define app settings such as the icon or the build number. Below you find an example of the structure of the file:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDevelopmentRegion</key>
	<string>$(DEVELOPMENT_LANGUAGE)</string>
	<key>CFBundleDisplayName</key>
	<string>MyApp</string>
	<key>CFBundleExecutable</key>
	<string>$(EXECUTABLE_NAME)</string>
	...
	<key>AppIdentifierPrefix</key>
	<string>$(AppIdentifierPrefix)</string>
</dict>
</plist>

Info.plist files are created when developers create a new target (e.g. a new iOS application). Most of the entries in the file are necessary but they are barely modified after the file is created. The entries that developers change the most is the version and build numbers. For others like the main storyboard or the icon, Xcode provides a UI interface to change the value.

As you might know, one of the aims of Tuist is abstracting away the details that we believe developers shouldn’t be exposed to. I think those Info.plist values that are barely touched are a perfect candidate for abstraction.

I’ve been thinking about how that abstraction would be, and this is the idea I came up with:

enum InfoPlist {
    case file(Path)
    case dictionary([String: Any])
    static func productDefaults(extend: [String: Any]) -> InfoPlist
}

Below you find some examples of how the definition of the target would look. Note that for simplicity of the examples, the targets don’t take all the arguments that are required:

// Existing Info.plist file
let watchApp = Target(name: "MyWatchApp", infoPlist: .file("./WatchApp.plist"))

// Generated Info.plist file with the given dictionary
let framework = Target(name: "MyFramework", infoPlist: .dictionary([
  "CFBundleExecutable": "$(EXECUTABLE_NAME)",
  "AppIdentifierPrefix": "$(AppIdentifierPrefix)"
]))

// Generated Info.plist file extending the platform default values.
let app = Target(name: "MyApp", infoPlist: .productDefaults(extend: [
  "CFBundleInfoDictionaryVersion": "6.0",
  "CFBundleVersion": "1.0"
]))

One of the most beautiful features of Tuist in my humble opinion is that manifests are written in Swift. That allows us to take benefit of extensions and Foundation protocols to simplify the interface:

extension InfoPlist: ExpressibleByDictionaryLiteral {
    init(dictionaryLiteral elements: (String, Any)...) {
        self = .dictionary(Dictionary(uniqueKeysWithValues: elements))
    }
}

extension InfoPlist: ExpressibleByStringLiteral {
    init(stringLiteral value: String) {
        self = .file(value)
    }
}

Thanks to those extensions, we can turn the examples into:

// Existing Info.plist file
let watchApp = Target(name: "MyWatchApp", infoPlist: "./WatchApp.plist")

// Generated Info.plist file with the given dictionary
let framework = Target(name: "MyFramework", infoPlist: [
  "CFBundleExecutable": "$(EXECUTABLE_NAME)",
  "AppIdentifierPrefix": "$(AppIdentifierPrefix)"
])

By giving Tuist more control over those files, we can run validations and verify that they contain the required attributes with the right values. As an example, watchOS extensions have a strict requirement when it comes to the bundle id of the extension. Since that’s an attribute configured in the Info.plist, we could verify that the value is right according to the watch app they are associated to.

Although developers can git-ignore those files because they get generated automatically when they run Tuist, I’d encourage them to keep them in the repository so that developers can checkout any revision of the project and compile it with Xcode without having to install Tuist.

An idea that I’m still pondering is how to structure the directory that contains the generated files. Info.plist files will be the first ones living in this directory but I’m sure they won’t be the only ones. Here’s a rough idea that I just had:

./Project.swift
./Generated
   InfoPlists/
     App.plist
     MyFramework.plist

I’ll sleep over the idea experiment with it to see how it feels in practice. If you have an opinion on it and don’t mind sharing, I’d appreciate it a lot. Don’t hesitate to ping me on Twitter, send me an email, our join Tuist’s Slack where you can talk to other contributors and maintainers.

I wanted to start coding on my flight ✈️ to Alicante but unfortunately, I forgot to run swift package generate-xcodeproj and now I can’t fetch the dependencies to do some coding 😕.

About Pedro Piñera

I created XcodeProj and Tuist, and co-founded Tuist Cloud. My work is trusted by companies like Adidas, American Express, and Etsy. I enjoy building delightful tools for developers and open-source communities.