PostProcessBuild で Info.plist を編集するための仕組みを作ってみた


このエントリは [Unity Advent Calendar 2015](http://qiita.com/advent-calendar/2015/unity) の9日目の記事になります。

前日の記事は [@fakestarbaby](http://qiita.com/fakestarbaby) さんの「[Visual Studio Code を利用して Unity アプリをデバッグする](http://qiita.com/fakestarbaby/items/8ddc9e779bccbd08b3b2)」でした。

ログ仕込んでデバッグするのもアリですが、やっぱりちゃんとブレークポイント仕込んでデバッグできると楽ですよね！

私も最近 Visual Studio Code 遣いになったので、ガンガン活用していこうかと思います！

# 前置き

Unity で iOS 向けにビルドした後に、 **`Info.plist` を編集する** という作業が発生する人も多いのではないでしょうか？

例えば、

- ATS (App Transport Security) 関連の設定
- URL Schemes の設定
- オリエンテーションに関する設定を書き換え

…などなど。

この辺、毎回手動で書き換えるのは面倒なので、自動化しちゃいましょうってなお話です。

# `plist` って何よ？

本題に入る前に、まず `plist` という形式について理解しておく必要があります。

成り立ちとかそういう小難しいコトは [Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3%E3%83%AA%E3%82%B9%E3%83%88) に説明を譲るとして、端的に言えば **XML で記述されるデータ定義ファイル** のコトです。

こいつには、

- そもそも XML である
- 型に応じたノードが定義されている
- `Dictionary` (連想配列) は `<key>` ノードと対になる型ノードを連続して記述する

といった、クセがあって、サックリ書き換えようと思っても、なかなか難しかったりします。

実際に、iOS 出力した際に生成される `Info.plist` をテキストエディタで開いてみれば、「嗚呼…面倒くさい…。」となるコト請け合いです。ええ。

# 汎用的に編集するための作戦

この面倒くさいフォーマットを攻略するための戦略を立ててみましょう。

- `System.Xml` のクラスを使うコトを前提とする
- いわゆるスカラーなノードは単一のテキストノードとして取り扱う
- `array` ノードと `dict` ノードはちょっと頑張る必要あり
- `dict` ノードに於けるキーの重複は上書きで対応する
	- ただし、再帰的に潜れる場合は、末端のノードでのみ上書きを行う
- `array` ノードは原則的に追加のみ
- ノードの削除も実装できるとオシャレ

この辺りを定義したパッチ的なファイルを読み込むことで、 `plist` ファイルを編集できたら楽ですよね？

# `PlistMods`

と、言うわけで作ってみました。

## 変更定義ファイル

`.plistmods` という拡張子を持つ `JSON` ファイルとして変更方法を定義してみました。

要点としては以下の通り。

- `{ "type": ..., "value": ... }` という1つ分の plist ノードを表現するための `Object` ノードを持つ
- `type` の値に応じて `value` ノードの型も変わる
	- `array` なら `Array` ノードを取る
	- `dict` なら plist 的な `key` ノードの値を、そのままキーとした `Object` ノードの連想配列とする
- `dict` なノードのキーをハイフン始まりにすると、「該当のキーと、それに隣接する値ノードを削除する」とみなす

まぁ、サンプル見ればある程度理解はできるんじゃないかと。

```json:sample.plistmods
{
  "type": "dict",
  "value": {
    "-CFBundleIconFiles": null,
    "-UISupportedInterfaceOrientations": null,
    "-UISupportedInterfaceOrientations~ipad": null,
    "UISupportedInterfaceOrientations": {
      "type": "array",
      "value": [
        {
          "type": "string",
          "value": "UIInterfaceOrientationLandscapeLeft"
        },
        {
          "type": "string",
          "value": "UIInterfaceOrientationLandscapeRight"
        }
      ]
    },
    "UISupportedInterfaceOrientations~ipad": {
      "type": "array",
      "value": [
        {
          "type": "string",
          "value": "UIInterfaceOrientationLandscapeLeft"
        },
        {
          "type": "string",
          "value": "UIInterfaceOrientationLandscapeRight"
        }
      ]
    },
    "NSAppTransportSecurity": {
      "type": "dict",
      "value": {
        "NSExceptionDomains": {
          "type": "dict",
          "value": {
            "kidsstar.tv": {
              "type": "dict",
              "value": {
                "NSExceptionAllowsInsecureHTTPLoads": {
                  "type": "bool",
                  "value": true
                },
                "NSExceptionRequiresForwardSecrecy": {
                  "type": "bool",
                  "value": false
                },
                "NSIncludesSubdomains": {
                  "type": "bool",
                  "value": true
                }
              }
            },
            "cloudfront.net": {
              "type": "dict",
              "value": {
                "NSExceptionAllowsInsecureHTTPLoads": {
                  "type": "bool",
                  "value": true
                },
                "NSExceptionRequiresForwardSecrecy": {
                  "type": "bool",
                  "value": false
                },
                "NSIncludesSubdomains": {
                  "type": "bool",
                  "value": true
                }
              }
            }
          }
        }
      }
    },
    "CFBundleURLTypes": {
      "type": "array",
      "value": [
        {
          "type": "dict",
          "value": {
            "CFBundleTypeRole": {
              "type": "string",
              "value": "Editor"
            },
            "CFBundleURLName": {
              "type": "string",
              "value": "tv.kidsstar.app.${PRODUCT_NAME:rfc1034identifier}"
            },
            "CFBundleURLSchemes": {
              "type": "array",
              "value": [
                {
                  "type": "string",
                  "value": "tv.kidsstar.app.${PRODUCT_NAME:rfc1034identifier}"
                }
              ]
            }
          }
        }
      ]
    }
  }
}
```

## `.plistmods` の適用

んで、 `.plistmods` な定義ファイルを読み込んで、 `Info.plist` を編集する感じの動きになります。

まぁ、実際のコードを見てもらうのが早いです。

```csharp:PlistMods.cs
using UnityEngine;
using System.IO;
using System.Xml;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using MiniJSON;

namespace KidsStar.Editor.iOS {

    public class PlistMods {

        private XmlDocument plist;

        public PlistMods(string path) {
            this.plist = new XmlDocument();
            this.plist.Load(path);
        }

        public void Apply(string pathPlistMods) {
            IDictionary mods = (IDictionary)Json.Deserialize(File.ReadAllText(pathPlistMods));

            // 第1階層が dict ノードであることを前提としている
            IDictionary rootItem = (IDictionary)mods["value"];
            XmlNode rootNode = this.plist.SelectSingleNode("/plist/dict");
            foreach (object key in rootItem.Keys) {
                this.Apply(rootNode, (string)key, (IDictionary)rootItem[key]);
            }
        }

        public void Apply(string[] pathPlistModsList) {
            foreach (string pathPlistMods in pathPlistModsList) {
                this.Apply(pathPlistMods);
            }
        }

        public void Apply(List<string> pathPlistModsList) {
            this.Apply(pathPlistModsList.ToArray());
        }

        public void Save(string path) {
            XmlDocumentType _documentType = this.plist.CreateDocumentType("plist", "-//Apple//DTD PLIST 1.0//EN", "http://www.apple.com/DTDs/PropertyList-1.0.dtd", null);
            if (null != this.plist.DocumentType) {
                this.plist.RemoveChild(this.plist.DocumentType);
            }
            this.plist.InsertAfter(_documentType, this.plist.FirstChild);
            this.plist.Save(path);
        }

        private void Apply(XmlNode parent, string key, IDictionary item) {
            // キーがハイフンで始まる場合、削除とみなす
            if (!string.IsNullOrEmpty(key) && Regex.IsMatch(key, "^-")) {
                key = Regex.Replace(key, "^-", string.Empty);
                if (parent.HasKeyNode(key)) {
                    parent.RemoveChild(parent.GetKeyNode(key).NextSibling);
                    parent.RemoveChild(parent.GetKeyNode(key));
                }
                return;
            }

            if (null == item["type"] || null == item["value"]) {
                Debug.LogError("処理対象のノードに type か value が含まれていません");
                return;
            }
            switch ((string)item["type"]) {
                case "bool":
                    this.ApplyScalar(parent, key, (bool)item["value"] ? "true" : "false");
                    break;
                case "integer":
                    this.ApplyScalar(parent, key, "integer", (int)item["value"]);
                    break;
                case "real":
                    this.ApplyScalar(parent, key, "real", (float)item["value"]);
                    break;
                case "string":
                    this.ApplyScalar(parent, key, "string", (string)item["value"]);
                    break;
                case "date":
                    this.ApplyScalar(parent, key, "date", (string)item["value"]);
                    break;
                case "data":
                    this.ApplyScalar(parent, key, "data", (string)item["value"]);
                    break;
                case "array":
                    this.ApplyArray(parent, key, (IList)item["value"]);
                    break;
                case "dict":
                    this.ApplyDict(parent, key, (IDictionary)item["value"]);
                    break;
            }
        }

        private void ApplyScalar(XmlNode parent, string key, string type, object value = null) {
            // キーが空の場合、Array ノードへの挿入と見なす
            if (string.IsNullOrEmpty(key)) {
                if (null == value) {
                    parent.AppendChild(this.plist.CreateElement(type));
                } else {
                    parent.AppendChild(this.plist.CreateSimpleTextNode(type, value.ToString()));
                }
                return;
            }

            if (parent.HasKeyNode(key)) {
                parent.RemoveChild(parent.GetKeyNode(key).NextSibling);
            } else {
                parent.AppendChild(this.plist.CreateKeyNode(key));
            }
            if (null == value) {
                parent.InsertAfter(this.plist.CreateElement(type), parent.GetKeyNode(key));
            } else {
                parent.InsertAfter(this.plist.CreateSimpleTextNode(type, value.ToString()), parent.GetKeyNode(key));
            }
        }

        private void ApplyArray(XmlNode parent, string key, IList itemList) {
            // キーが空の場合、Array ノードへの挿入と見なす
            if (string.IsNullOrEmpty(key)) {
                foreach (object item in itemList) {
                    this.Apply(parent, null, (IDictionary)item);
                }
                return;
            }

            if (!parent.HasKeyNode(key)) {
                // キーがないなら、キーノードと array ノードを作る
                parent.AppendChild(this.plist.CreateKeyNode(key));
                parent.AppendChild(this.plist.CreateElement("array"));
            }
            foreach (object item in itemList) {
                this.Apply(parent.GetKeyNode(key).NextSibling, null, (IDictionary)item);
            }
        }

        private void ApplyDict(XmlNode parent, string key, IDictionary item) {
            // キーが空の場合、Array ノードへの挿入と見なす
            if (string.IsNullOrEmpty(key)) {
                XmlNode dictNode = parent.AppendChild(this.plist.CreateElement("dict"));
                foreach (object k in item.Keys) {
                    this.Apply(dictNode, (string)k, (IDictionary)item[k]);
                }
                return;
            }

            if (!parent.HasKeyNode(key)) {
                // キーがないなら、キーノードと dict ノードを作る
                parent.AppendChild(this.plist.CreateKeyNode(key));
                parent.AppendChild(this.plist.CreateElement("dict"));
            }
            foreach (object k in item.Keys) {
                this.Apply(parent.GetKeyNode(key).NextSibling, (string)k, (IDictionary)item[k]);
            }
        }
    }

    internal static class XmlExtension {

        public static bool HasChildNode(this XmlNode self, string name, bool ignoreCase = true) {
            return null != self.GetChildNode(name, ignoreCase);
        }

        public static XmlNode GetChildNode(this XmlNode self, string name, bool ignoreCase = true) {
            foreach (XmlNode childNode in self.ChildNodes) {
                if (childNode.Name == name || (ignoreCase && childNode.Name.ToLower() == name.ToLower())) {
                    return childNode;
                }
            }
            return null;
        }

        public static bool HasKeyNode(this XmlNode self, string key) {
            return null != self.GetKeyNode(key);
        }

        public static XmlNode GetKeyNode(this XmlNode self, string key) {
            return self.SelectSingleNode(string.Format("./key[.=\"{0}\"]", key));
        }

        public static XmlNode CreateSimpleTextNode(this XmlDocument xml, string name, string text) {
            XmlElement _node = xml.CreateElement(name);
            _node.InnerText = text;
            return _node;
        }

        public static XmlNode CreateKeyNode(this XmlDocument xml, string key) {
            return xml.CreateSimpleTextNode("key", key);
        }

    }
}
```

[MiniJSON](https://gist.github.com/darktable/1411710) を使っていますが、昨日 (2015/12/08) リリースされた Unity 5.3 であれば標準で JSON Serializer を搭載しているので、それ使ってあげるように修正しても良いかもです。

`Save()` メソッドで `DOCTYPE` ノードを再構築していますが、普通に保存すると何故か `[]` という記号が最後に挿入されてしまって、ビルドエラーをぶちかましてくれるので、泣く泣く強引な処理を噛ましています。

なお、現時点で、 **`data` ノードが正しく取り扱われない** というバグがあります！

「対応したぜ！」って人が居れば、後述のサンプルプロジェクトにプルリク飛ばしてくださいｗ

## PostProcessBuild での適用

この辺は、本題じゃないんで、サラッと流します。

```csharp:PostProcessBuild.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using System.IO;
using System.Collections.Generic;

namespace Sample.Editor {

    public class PostProcessBuild {

        [PostProcessBuild(200)]
        public static void OnPostProcessBuild(BuildTarget target, string path) {
            if (target != BuildTarget.iOS) {
                return;
            }

            // 適用する .plistmods ファイルを掻き集める
            List<string> files = new List<string>();
            if (Directory.Exists(System.IO.Path.Combine(Application.dataPath, "Sample/PlistMods"))) {
                files.AddRange(System.IO.Directory.GetFiles(System.IO.Path.Combine(Application.dataPath, "Sample/PlistMods"), "*.plistmods", System.IO.SearchOption.AllDirectories));
            }

            // .plistmods ファイルを適用する
            string plistPath = Path.Combine(path, "Info.plist");
            KidsStar.Editor.iOS.PlistMods plistMods = new KidsStar.Editor.iOS.PlistMods(plistPath);
            plistMods.Apply(files);
            plistMods.Save(plistPath);
        }

    }

}
```

`PlistMods.Apply()` の引数として、

- `string` として、単一の `.plistmods` へのパス
- `string[]` として、複数の `.plistmods` へのパス
- `List<string>` として、複数の `.plistmods` へのパス

というオーバーロードを用意してありますので、お好きな方法でどーぞ。

あ、ちなみに、 `Info.plist` に限らず、 `plist` 形式ならどんなファイルでも行けるようにしてあるので、独自プロパティリストを使うような SDK とかをアレコレしたい場合なんかにも使えるんじゃないかな？

## サンプルプロジェクト

[github](https://github.com/kidsstar/Sample20151209) にて公開してあります。

こいつをそのまま取り込んで iOS 向けビルドしていただければ `Assets/Sample/PlistMods/sample.plistmods` の変更定義に従って Info.plist が書き換わるハズです。

Unity のバージョンは 5.2.3f1 で確認済となります。

# まとめ

実は、Unity 5 からは、 `UnityEditor.iOS.Xcode` なるネームスペースの下に `PlistDocument` というクラスが生えていて、こいつを駆使してあげれば同じようなことは実現出来るんですが、追加・変更・削除の処理そのものは、個別に実装する必要があるため、こんなモノを作ってみた次第です。

同じような感じで Android の `AndroidManifest.xml` の編集とか、 iOS の `.pbxproj` (Xcode の Build Settings とかの設定が定義されているファイル) の編集とかも実装してみたら面白いんじゃないでしょーか？

むしろ、誰か作って！ｗ