Implementing unsuported iOS capabilities in Unity

I needed to add a capability to the tvOS version of an app that wasn’t on the ProjectCapabilityManager class, so I digged around a bit and realized the capabilities file was a plist so I could use the PlistDocument class to edit it. My solution was really simple but as I couldn’t find anything online I decided to write a post about it.

This is implemented on my asset Unity Autobuilder, an open source project.

In this case I needed to implement the User Management capability, a relatively new feature, looking in the documentation I found the definition and saw I would have to add the key com.apple.developer.user-management and add the possible values get-current-user and runs-as-current-user as an array of strings.

So I went about creating a PlistDocument() and adding the array. This is a snippet from the Autobuilder asset, in wich the capabilities are configured in a menu:

            // Unsuported capabilities
            var plist = new PlistDocument();
            plist.ReadFromFile(entitlementsFilePath);
            for ( int i = 0; i < Capabilities.Count; i++ ) {
                var node = Capabilities[i];
                var type = (XCodeModule.CapabilityType) node[XCodeModule.CAPABILITY_TYPE].AsInt;
                
                switch ( type ) {
                    case XCodeModule.CapabilityType.UserManagement:
                        var array = plist.root.CreateArray("com.apple.developer.user-management");
                        if (node[XCodeModule.GET_CURRENT_USER]) {
                            array.AddString("get-current-user");
                        }
                        if (node[XCodeModule.RUNS_AS_CURRENT_USER]) {
                            array.AddString("runs-as-current-user");
                        }
                        break;
                }
            }
            plist.WriteToFile(entitlementsFilePath);

As you can see, it’s really easy to implement.

Breakdown

First we need to create a PlistDocument and read from the capabilities file, you’ll need the entitlements file path, wich is the name of the project with the .entitlements extension. As you need to do this in a build post processor, you’ll get the directory as one of the parameters of OnPostprocessBuild. In the Autobuilder, the name of the output file is saved in Builder.FileName, so the path is var entitlementsFilePath = pathToBuiltProject + "/" + Builder.FileName + ".entitlements";.

var plist = new PlistDocument();
plist.ReadFromFile(entitlementsFilePath);

To add a capability we simply need to add a key to the root dictionary, in this case we create an array and add strings to it, in each case you’ll need to check the documentation.

var array = plist.root.CreateArray("com.apple.developer.user-management");
array.AddString("get-current-user");
array.AddString("runs-as-current-user");

In the end you need to write to the file.

plist.WriteToFile(entitlementsFilePath);

This is the script you can find in the Autobuilder repo (the configuration is saved in a json file):

#if UNITY_IOS || UNITY_TVOS
using System.IO;
using UnityEngine;
using UnityEditor;
using UnityEditor.iOS.Xcode;
using UnityEditor.Callbacks;
using Autobuilder.SimpleJSON;
using System.Collections.Generic;

namespace Autobuilder {
    public class XCodePostProcessor {
        [PostProcessBuildAttribute(0)]
        public static void OnPostprocessBuild(BuildTarget buildTarget, string pathToBuiltProject) {
            if ( buildTarget != BuildTarget.iOS && buildTarget != BuildTarget.tvOS )
                return;

            ProcessPbxProject(buildTarget, pathToBuiltProject);
            // TODO: Turn this into generic for future projects
            ProcessInfoPlist(buildTarget, pathToBuiltProject);
        }

        public static void ProcessPbxProject(BuildTarget buildTarget, string pathToBuiltProject) {
            var targetName = "Unity-iPhone";

            var pbxProjectPath = pathToBuiltProject + "/Unity-iPhone.xcodeproj/project.pbxproj";
            var pbxProject = new PBXProject();
            pbxProject.ReadFromFile(pbxProjectPath);
#if UNITY_2020_2_OR_NEWER
            var targetGuid = pbxProject.GetUnityMainTargetGuid();
#else
            var targetGuid = pbxProject.TargetGuidByName(targetName);
#endif

            var entitlementsFileName = Builder.FileName + ".entitlements";
            var entitlementsFilePath = pathToBuiltProject + "/" + entitlementsFileName;

            pbxProject.AddFile(entitlementsFilePath, entitlementsFileName);
            pbxProject.SetBuildProperty(targetGuid, "CODE_SIGN_ENTITLEMENTS", entitlementsFileName);
            pbxProject.SetBuildProperty(targetGuid, "ENABLE_BITCODE", "YES");

            pbxProject.WriteToFile(pbxProjectPath);

            var capabilityManager = new ProjectCapabilityManager(pbxProjectPath, entitlementsFilePath, targetName);
            JSONArray Capabilities;
            JSONArray Files;
            if ( buildTarget == BuildTarget.iOS ) {
                var module = new IOSModule();
                Capabilities = module.Capabilities;
                Files = module.Files;
            } else if ( buildTarget == BuildTarget.tvOS ) {
                var module = new TVOSModule();
                Capabilities = module.Capabilities;
                Files = module.Files;
            } else {
                return;
            }

            for ( int i = 0; i < Capabilities.Count; i++ ) {
                var node = Capabilities[i];
                var type = (XCodeModule.CapabilityType) node[XCodeModule.CAPABILITY_TYPE].AsInt;
                
                switch ( type ) {
                    case XCodeModule.CapabilityType.iCloud:
                        bool enableKeyValueStorage = false;
                        bool enableiCloudDocument = false;
                        bool enableCloudKit = false;

                        var subNode = node[XCodeModule.ENABLE_KEYVALUE_STORAGE];
                        if ( subNode != null && subNode.IsBoolean ) {
                            enableKeyValueStorage = subNode.AsBool;
                        }

                        subNode = node[XCodeModule.ENABLE_ICLOUD_DOCUMENT];
                        if ( subNode != null && subNode.IsBoolean ) {
                            enableiCloudDocument = subNode.AsBool;
                        }

                        subNode = node[XCodeModule.ENABLE_CLOUDKIT];
                        if ( subNode != null && subNode.IsBoolean ) {
                            enableCloudKit = subNode.AsBool;
                        }

                        string[] customContainers;
                        subNode = node[XCodeModule.ICLOUD_CUSTOM_CONTAINERS];
                        if ( subNode != null && subNode.IsArray ) {
                            List<string> containersList = new List<string>();
                            foreach ( JSONNode item in subNode.AsArray ) {
                                if ( item.IsString ) {
                                    containersList.Add(item.Value);
                                }
                            }
                            customContainers = containersList.ToArray();
                        } else {
                            customContainers = new string[0];
                        }
                        // Add iCloud
                        capabilityManager.AddiCloud(
                            enableKeyValueStorage, enableiCloudDocument, enableCloudKit,
                            false, customContainers);
                        break;
                    case XCodeModule.CapabilityType.AssociatedDomains:
                        string[] associatedDomains;
                        subNode = node[XCodeModule.ASSOCIATED_DOMAINS];
                        if ( subNode != null && subNode.IsArray ) {
                            List<string> containersList = new List<string>();
                            foreach ( JSONNode item in subNode.AsArray ) {
                                if ( item.IsString ) {
                                    containersList.Add(item.Value);
                                }
                            }
                            associatedDomains = containersList.ToArray();
                        } else {
                            associatedDomains = new string[0];
                        }
                        capabilityManager.AddAssociatedDomains(associatedDomains);
                        
                        break;
                }
            }
            capabilityManager.WriteToFile();

            // Unsuported capabilities
            var plist = new PlistDocument();
            plist.ReadFromFile(entitlementsFilePath);
            for ( int i = 0; i < Capabilities.Count; i++ ) {
                var node = Capabilities[i];
                var type = (XCodeModule.CapabilityType) node[XCodeModule.CAPABILITY_TYPE].AsInt;
                
                switch ( type ) {
                    case XCodeModule.CapabilityType.UserManagement:
                        var array = plist.root.CreateArray("com.apple.developer.user-management");
                        if (node[XCodeModule.GET_CURRENT_USER]) {
                            array.AddString("get-current-user");
                        }
                        if (node[XCodeModule.RUNS_AS_CURRENT_USER]) {
                            array.AddString("runs-as-current-user");
                        }
                        break;
                }
            }
            plist.WriteToFile(entitlementsFilePath);


            for (int i = 0; i < Files.Count; i++) {
                var file = Files[i].Value;
                Debug.Log(file);
                if (Directory.Exists(file)) {
                    Debug.Log("\tIs a directory");
                    Directory.Move(file, Path.Combine(pathToBuiltProject, Path.GetDirectoryName(file)));
                } else if (File.Exists(file)) {
                    Debug.Log("\tIs a file");
                    File.Move(file, Path.Combine(pathToBuiltProject, Path.GetFileName(file)));
                }
            }
        }

        public static void ProcessInfoPlist(BuildTarget buildTarget, string pathToBuiltProject) {
            var plistPath = Path.Combine(pathToBuiltProject, "Info.plist");
            if ( !File.Exists(plistPath) ) return;
            
            var plist = new PlistDocument();
            plist.ReadFromFile(plistPath);

            JSONObject plistData;
            if ( buildTarget == BuildTarget.iOS ) {
                plistData = new IOSModule().Plist;
            } else if ( buildTarget == BuildTarget.tvOS ) {
                plistData = new TVOSModule().Plist;
            } else {
                return;
            }

            AddObjectToDocument(plist, plistData);
            plist.WriteToFile(plistPath);
        }

        static void AddObjectToDocument(PlistDocument document, JSONObject node) {
            AddDictToElement(document.root, node);
        }

        static void AddArrayToElement(PlistElementArray element, JSONArray node) {
            foreach ( JSONNode item in node ) {
                AddElementToArray(element, item);
            }
        }

        static void AddDictToElement(PlistElementDict element, JSONObject node) {
            foreach ( var itemKey in node.Keys ) {
                AddElementToDict(element, itemKey, node[itemKey]);
            }
        }

        static void AddElementToDict(PlistElementDict element, string key, JSONNode node) {
            if ( node.IsArray ) {
                var array = element.CreateArray(key);
                AddArrayToElement(array, node.AsArray);
            } else if ( node.IsBoolean ) {
                element.SetBoolean(key, node.AsBool);
            } else if ( node.IsString ) {
                element.SetString(key, node.Value);
            } else if ( node.IsNumber ) {
                if ( node.Value.Contains(".") ) {
                    element.SetReal(key, node.AsFloat);
                } else {
                    element.SetInteger(key, node.AsInt);
                }
            } else if ( node.IsObject ) {
                var dict = element.CreateDict(key);
                AddDictToElement(dict, node.AsObject);
            } else if ( node.IsNull ) {
                element.values.Remove(key);
            }
        }

        static void AddElementToArray(PlistElementArray element, JSONNode node) {
            if ( node.IsBoolean ) {
                element.AddBoolean(node.AsBool);
            } else if ( node.IsString ) {
                element.AddString(node.Value);
            } else if ( node.IsNumber ) {
                if ( node.Value.Contains(".") ) {
                    element.AddReal(node.AsFloat);
                } else {
                    element.AddInteger(node.AsInt);
                }
            } else if ( node.IsArray ) {
                var array = element.AddArray();
                AddArrayToElement(array, node.AsArray);
            } else if ( node.IsObject ) {
                var dict = element.AddDict();
                AddDictToElement(dict, node.AsObject);
            }
        }
    }
}
#endif

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s